import logging
import logging.handlers
import logging.config
import pickle
import tempfile
from functools import wraps
from typing import Any
import click
from sertit import AnyPath
# Arcpy types from inside a schema
SHORT = "int32:4"
""" 'Short' type for ArcGis GDB """
LONG = "int32:10"
""" 'Long' type for ArcGis GDB """
FLOAT = "float"
""" 'Float' type for ArcGis GDB """
DOUBLE = "float"
""" 'Double' type for ArcGis GDB """
TEXT = "str:255"
""" "Text" type for ArcGis GDB """
DATE = "datetime"
""" 'Date' type for ArcGis GDB """
AGP_EO_ENV = "arcgispro-eo-backend"
AGP_EO_ENV_TEST = "arcgispro-eo-backend-testing"
AGP_ENVS = [AGP_EO_ENV, AGP_EO_ENV_TEST]
""" ArcGis Pro environments """
# flake8: noqa
[docs]
def init_conda_arcpy_env():
"""
Initialize conda environment with Arcgis Pro.
Resolves several issues.
"""
try: # pragma: no cover
from packaging.version import InvalidVersion, Version
try:
import fiona
from fiona import Env as fiona_env
with fiona_env():
gdal_version = fiona.env.get_gdal_release_name()
Version(gdal_version)
except InvalidVersion:
# workaround to https://community.esri.com/t5/arcgis-pro-questions/arcgispro-py39-gdal-version-3-7-0e-is-recognized/m-p/1364021
import geopandas as gpd
gpd.options.io_engine = "pyogrio"
except (ModuleNotFoundError, ImportError): # pragma: no cover
pass
[docs]
class ArcPyLogger: # pragma: no cover
def __init__(self, name=None, prefix_log_file="atools_"):
"""
This class inits a ready to use python logger for ArcGis pro tool. Be sure that arcpy has been imported
before using this class. It uses logging under the hood.
It writes outputs to a temporary file and to the ArcGis console.
The temporary file is removed when the user closes ArcGis.
If you need a logger in an outside module or function, use :code:`logging.getLogger(LOGGER_NAME)`
to get your logger.
Args:
name (str) : The name of the logger
prefix_log_file (str) : The log filename is random, but you can prefix a name.
The default value is "{{ name }}_".
Example:
>>> import logging
>>> from sertit.arcpy import ArcPyLogger
>>> arcpy_logger = ArcPyLogger(name="MyArcgisTool")
Outputs written to file: C:\\Users\\bcoriat\\AppData\\Local\\Temp\\ArcGISProTemp15788\\MyArcgisTool_1bv0c1cl
>>> logger = logging.getLogger("MyArcgisTool")
>>> logger.info("Hello World !")
Hello World !
Warning:
Python must keep a reference to the instantiated object during the execution of your program.
That's why you must init this class once at the top level of your project.
This will not work because Python destroys the object class.
>>> ArcPyLogger(name="MyArcgisTool")
>>> logger = logging.getLogger("MyArcgisTool")
>>> logger.info("Hello World !")
"""
self.name = name
self.logger = None
self.handler = None
if name:
self.prefix = name + "_"
else:
self.prefix = prefix_log_file
self._set_logger()
def __del__(self):
self.logger.removeHandler(self.handler)
def _set_logger(self):
import tempfile
logger = logging.getLogger(self.name)
f = tempfile.NamedTemporaryFile(prefix=self.prefix, delete=False)
# Create handler
max_file_size = 1024 * 1024 * 2 # 2MB log files
self.handler = ArcPyLogHandler(
f.name,
maxBytes=max_file_size,
backupCount=10,
encoding="utf-8",
)
logger.addHandler(self.handler)
# Set formatter to handler
formatter = logging.Formatter("%(levelname)-8s %(message)s")
self.handler.setFormatter(formatter)
# Set logger
logger.setLevel(logging.DEBUG)
self.logger = logger
self.logger.info("You can read logs in the file: " + f.name)
[docs]
class ArcPyLogHandler(logging.handlers.RotatingFileHandler): # pragma: no cover
"""
Custom logging class that bounces messages to the arcpy tool window as well
as reflecting back to the file.
"""
[docs]
def emit(self, record):
"""
Write the log message
"""
import arcpy
try:
msg = record.msg % record.args
except:
try:
msg = record.msg.format(record.args)
except:
msg = record.msg
if record.levelno >= logging.ERROR:
arcpy.AddError(msg)
elif record.levelno >= logging.WARNING:
arcpy.AddWarning(msg)
elif record.levelno >= logging.INFO:
arcpy.AddMessage(msg)
super(ArcPyLogHandler, self).emit(record)
[docs]
def init_arcgis_logger(
curr_logger: logging.Logger, log_lvl: int = logging.INFO
) -> None:
"""
Initialize a logger for tools running in the backend environment with `run_in_conda_env` function.
This logger outputs information in JSON format to stdout, the `run_in_conda_env` function catches the JSON and
print it properly to the user in ArcGis.
Args:
curr_logger (logging.Logger): Logger to be initialize
log_lvl (int): Logging level to be set
Example:
>>> logger = logging.getLogger("logger_test")
>>> init_logger(logger, logging.INFO)
>>> logger.info("MESSAGE")
"""
f = tempfile.NamedTemporaryFile(prefix="atools_", delete=False)
max_file_size = 1024 * 1024 * 2 # 2MB log files
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"fmt": {
"format": "%(levelname)-8s %(message)s",
}
},
"handlers": {
"arcgis": {
"()": "sertit.arcpy.ArcPyLogHandler",
"filename": f.name,
"maxBytes": max_file_size,
"backupCount": 10,
"encoding": "utf-8",
"level": logging.getLevelName(log_lvl),
"formatter": "fmt",
},
},
"loggers": {
curr_logger.name: {
"handlers": ["arcgis"],
"propagate": False,
"level": logging.getLevelName(log_lvl),
}
},
}
)
curr_logger.info("You can read logs in the file: " + f.name)
[docs]
def init_json_logger(curr_logger: logging.Logger, log_lvl: int = logging.INFO) -> None:
"""
Initialize a logger for tools running in the backend environment with `run_in_conda_env` function.
This logger outputs information in JSON format to stdout, the `run_in_conda_env` function catches the JSON and
print it properly to the user in ArcGis.
Args:
curr_logger (logging.Logger): Logger to be initialize
log_lvl (int): Logging level to be set
Example:
>>> logger = logging.getLogger("logger_test")
>>> init_logger(logger, logging.INFO)
>>> logger.info("MESSAGE")
"""
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"fmt": {
"()": "pythonjsonlogger.json.JsonFormatter",
"fmt": ["message", "asctime", "exc_info", "levelname"],
}
},
"handlers": {
"stream": {
"level": logging.getLevelName(log_lvl),
"class": "logging.StreamHandler",
"formatter": "fmt",
},
},
"loggers": {
curr_logger.name: {
"handlers": ["stream"],
"propagate": False,
"level": logging.getLevelName(log_lvl),
}
},
}
)
[docs]
def gp_layer_to_path(layer) -> str: # pragma: no cover
"""
Convert a GP layer to its source path.
A GP layer in ArcGis is a layer in the content panel. Thus, the user can simply choose the layer in a dropdown menu.
This function adds the possibility to get the source path of this layer if the user chose in the dropdown menu or
drag and drop from the Windows explorer.
Args:
layer: Feature layer or Raster layer
Returns:
str: Path to the feature or raster layer source
Examples:
For python toolbox, in the getParameterInfo() method use GPLayer, GPFeatureLayer or GPRasterLayer datatype.
For vector layer use GPFeatureLayer:
>>> import arcpy
>>> aoi = arcpy.Parameter(
>>> displayName="Aoi",
>>> name="aoi",
>>> datatype="GPFeatureLayer",
>>> parameterType="Required",
>>> direction="Input",
>>> )
For raster layer, use GPRasterLayer:
>>> import arcpy
>>> nir_path = arcpy.Parameter(
>>> displayName="Nir infrared band",
>>> name="nir_path",
>>> datatype="GPRasterLayer",
>>> parameterType="Optional",
>>> direction="Input",
>>> )
If your layer may be a feature or raster layer, use GPLayer:
>>> import arcpy
>>> dem_path = arcpy.Parameter(
>>> displayName="DEM path as isoline or raster",
>>> name="dem_path",
>>> datatype="GPLayer",
>>> parameterType="Optional",
>>> direction="Input",
>>> )
Then in the execute() method, you can use this function to retrieve the real path to the layer.
>>> aoi_path = feature_layer_to_path(parameters[0].value)
>>> print(aoi_path)
D:/data/project/aoi/aoi.shp
"""
# Get path
if hasattr(layer, "dataSource"):
path = layer.dataSource
else:
path = str(layer)
# In case we have a raster, convert the path to one compatible with rasterio
if hasattr(layer, "isRasterLayer") and layer.isRasterLayer:
path = from_gdb_raster_to_rio_path(path)
return path
[docs]
def from_gdb_raster_to_rio_path(path_in_gdb: str) -> str:
"""Convert a raster stored in a gdb into a path readable by rasterio"""
path_obj = AnyPath(path_in_gdb)
if ".gdb" in str(path_obj.parent):
rio_path = rf"OpenFileGDB:{str(path_obj.parent)}:{path_obj.name}"
else:
rio_path = path_in_gdb
return rio_path
[docs]
def run_in_conda_env(
executable: list[str],
logger_name: str = "sertit_utils",
conda_env_name: str | None = None,
python_path: str = "",
extend_env: dict = {},
shell=True,
):
"""
This function runs an executable thanks to conda run in a python subprocess.
It uses the subcommand `conda run` to run the executable in a conda environment.
If conda_env_name is None, it chooses an environment in the following order (it stops at the first one found):
- arcgispro-eo-backend
- arcgispro-eo-backend-testing
- self
Where self is the current environment. If self is given, a subprocess is launched anyway with the same python interpreter.
This function is designed to solve ArcGis limits by running an executable files in another conda environment
and thus solves a lot of issues.
The `extend_env` argument allows one to add environment variable in the child subprocess.
For example, if parent_env = { "PYTHON_PATH": "/path/to/python_lib,/another_path/to/python_lib", "VAR": "True" }
and extend_env = {"PYTHON_PATH": "/child_path/to/python_lib,/another_path/to/python_lib"}
Then, child_env = {"PYTHON_PATH": "/child_path/to/python_lib,/another_path/to/python_lib", "VAR": "True"}
The child_env took the whole `PYTHON_PATH` variable from `extend_env` and the variable `VAR` from the parent_env.
Args:
executable: Executable name, with additional arguments to be passed to the executable on invocation.
logger_name: The logger name to use.
conda_env_name: Name of the conda environment where to run the executable.
Set it to "self" to force the subprocess to run in the current environment.
python_path: Set the PYTHON_PATH variable in the child subprocess.
extend_env: Dict to extend environment in the sub-process. extend_env is merged with
the parent process environment by overriding it if necessary.
shell: Run subprocess in a shell if True.
Returns:
"""
import json
import logging
import os
import pathlib
import subprocess
import sys
import platform
from sertit.exception import ListCondaEnvError
logger = logging.getLogger(logger_name)
list_env_cmd = ["conda", "env", "list", "--json"]
if platform.system() == "Windows":
CREATE_NO_WINDOW = 0x08000000
env_list = subprocess.run(
list_env_cmd,
capture_output=True,
creationflags=CREATE_NO_WINDOW,
)
else:
env_list = subprocess.run(
list_env_cmd,
capture_output=True,
)
if env_list.returncode > 0:
raise ListCondaEnvError(env_list.stderr)
env_list = json.loads(env_list.stdout)
current_env = env_list["default_prefix"]
current_env_name = pathlib.Path(current_env).name
if not conda_env_name:
available_env = []
available_prefix = []
env_path_list = env_list["envs"]
if env_path_list is None:
raise FileNotFoundError(f"No env found on your conda installation!")
# Find available environments
for env in env_path_list:
name = pathlib.Path(env).name
prefix = pathlib.Path(env).parent
if name in AGP_ENVS:
available_env.append(name)
available_prefix.append(str(prefix))
# Choose the most appropriate environment to run the command line
if len(available_env) > 0:
# AGP_EO_ENV has priority over AGP_EO_ENV_TEST
conda_env_name = (
AGP_EO_ENV if AGP_EO_ENV in available_env else AGP_EO_ENV_TEST
)
conda_env_prefix = available_prefix[available_env.index(conda_env_name)]
else:
# Default to the current environment if no backend environment is found
conda_env_name = current_env_name
conda_env_prefix = str(pathlib.Path(current_env).parent)
# Create conda path
conda_path = str(pathlib.Path(conda_env_prefix) / conda_env_name)
elif conda_env_name == "self":
conda_env_name = current_env_name
conda_env_prefix = str(pathlib.Path(current_env).parent)
conda_path = str(pathlib.Path(conda_env_prefix) / conda_env_name)
else:
conda_path = conda_env_name
if conda_path is None:
raise FileNotFoundError(f"Impossible to find your conda env!")
cmd_line = [
"conda",
"run",
"--live-stream",
"-p",
conda_path,
] + executable
# Copy and clean the environment
env = os.environ
clean_env = {}
for key, value in env.items():
value_as_list = value.split(";")
value_as_list_filtered = [
el for el in value_as_list if el.find(current_env_name) == -1
]
if len(value_as_list_filtered) > 0:
value_as_str = ";".join(value_as_list_filtered)
clean_env[key] = value_as_str
clean_env["PYTHONPATH"] = python_path
# Add env_extend allowing to overwrite
clean_env = clean_env | extend_env
clean_env["SERTIT_LOGGER_TYPE"] = "BACKEND_SUBPROCESS"
with subprocess.Popen(
cmd_line,
shell=shell,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=clean_env,
close_fds=False,
) as process:
for line in process.stdout:
line = line.decode(
encoding=sys.stdout.encoding,
errors=("replace" if sys.version_info < (3, 5) else "backslashreplace"),
).rstrip()
try:
line_json = json.loads(line)
message = line_json.get("message")
exc_info = line_json.get("exc_info")
if exc_info and line_json.get("levelname") == "ERROR":
logger.error(exc_info)
elif line_json.get("levelname") == "ERROR":
logger.error(message)
elif line_json.get("levelname") == "WARNING":
logger.warning(message)
else:
logger.info(message)
except Exception as e:
logger.info(line)
# Get return value
retval = process.wait(timeout=None)
# Kill process
process.kill()
return retval
@click.command(
context_settings=dict(
help_option_names=["-h", "--help"], max_content_width=300, show_default=True
)
)
@click.option(
"-i",
"--input-file",
help="Path to the pickle corresponding to the inputs of your function.",
type=str,
required=False,
)
@click.option(
"-o",
"--output-file",
help="Path to the pickle corresponding to the outputs of your function.",
type=str,
required=False,
)
def create_conda_env_cli(input_file: str, output_file: str):
"""
Create the CLI to run the decorated function in a new conda environment.
To be used in the backend CLI main.
Examples:
>>> from sertit.arcpy import create_conda_env_cli
>>> from my_tool.my_tool_core import my_tool_core
>>> create_conda_env_cli(standalone_mode=False)(my_tool_core)
"""
def decorator(function):
@wraps(function)
def wrapper(input_file, output_file):
with open(input_file, "rb") as fp_in:
inputs: dict = pickle.load(fp_in)
output = function(**inputs)
with open(output_file, "wb") as fp_out:
pickle.dump(output, fp_out)
return wrapper(input_file, output_file)
return decorator
[docs]
def run_in_conda_env_cli(
cli_path: str,
inputs: dict,
logger_name: str,
tools_path: str,
conda_env_name: str = "",
extend_env: dict = {},
shell: bool = True,
) -> tuple[int, Any]:
"""
Run the given command line with the given inputs in a new conda environment.
Scope: run a complicated tool with heavy deps from inside a conda environment ccontaining arcpy
To be used in the .pyt file.
Args:
cli_path (str): Path to CLI file.
inputs (dict): Inputs of the function wrapped by the CLI as a dictionary
logger_name (str): Logger name
tools_path (str): Path to the tools
conda_env_name (str): Name of the conda environment where to run the executable.
Set it to "self" to force the subprocess to run in the current environment.
extend_env (dict): Dict to extend environment in the sub-process. extend_env is merged with
the parent process environment by overriding it if necessary.
shell (bool): Run subprocess in a shell if True.
Returns:
tuple[int, Any]: Return value and outputs of the function
Examples:
>>> def main_arcgis(parameters, messages, tools_path):
>>> from sertit.arcpy import run_in_conda_env_cli
>>> from my_tool import LOGGER_NAME, my_backend_cli
>>>
>>> inputs = {
>>> "aoi_path": "aoi.shp",
>>> "input": "input.tif",
>>> "resolution": 10,
>>> }
>>>
>>> retval, output_path = run_in_conda_env_cli(
>>> cli_path=my_backend_cli.__file__,
>>> inputs=inputs,
>>> logger_name=LOGGER_NAME,
>>> tools_path=tools_path,
>>> )
>>>
>>> if retval == 0:
>>> logger.info("RasterFillHoles was a success.")
>>> else:
>>> logger.error("Subprocess RasterFillHoles failed.")
"""
with tempfile.NamedTemporaryFile(delete=False) as fp_in:
fp_in_name = fp_in.name
with tempfile.NamedTemporaryFile(delete=False) as fp_out:
fp_out_name = fp_out.name
with open(fp_in_name, "wb") as fp_in:
pickle.dump(inputs, fp_in)
cmd_line = [
"python",
str(cli_path),
"--input-file",
fp_in.name,
"--output-file",
fp_out.name,
]
retval = run_in_conda_env(
cmd_line,
conda_env_name=conda_env_name,
logger_name=logger_name,
python_path=tools_path,
extend_env=extend_env,
shell=shell,
)
if retval == 0:
with open(fp_out_name, "rb") as fp_out:
ret = pickle.load(fp_out)
else:
ret = None
return retval, ret