dolphin 0.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. dolphin/__init__.py +9 -0
  2. dolphin/__main__.py +9 -0
  3. dolphin/_cli_timeseries.py +144 -0
  4. dolphin/_cli_unwrap.py +150 -0
  5. dolphin/_decorators.py +148 -0
  6. dolphin/_log.py +137 -0
  7. dolphin/_overviews.py +174 -0
  8. dolphin/_show_versions.py +130 -0
  9. dolphin/_types.py +136 -0
  10. dolphin/_version.py +16 -0
  11. dolphin/atmosphere/__init__.py +33 -0
  12. dolphin/atmosphere/_netcdf.py +521 -0
  13. dolphin/atmosphere/_utils.py +241 -0
  14. dolphin/atmosphere/ionosphere.py +416 -0
  15. dolphin/atmosphere/model_levels.py +1247 -0
  16. dolphin/atmosphere/troposphere.py +505 -0
  17. dolphin/atmosphere/weather_model.py +867 -0
  18. dolphin/baseline.py +157 -0
  19. dolphin/cli.py +34 -0
  20. dolphin/filtering.py +194 -0
  21. dolphin/goldstein.py +99 -0
  22. dolphin/interferogram.py +780 -0
  23. dolphin/interpolation.py +214 -0
  24. dolphin/io/__init__.py +8 -0
  25. dolphin/io/_background.py +264 -0
  26. dolphin/io/_blocks.py +349 -0
  27. dolphin/io/_core.py +779 -0
  28. dolphin/io/_paths.py +197 -0
  29. dolphin/io/_process.py +58 -0
  30. dolphin/io/_readers.py +1078 -0
  31. dolphin/io/_utils.py +264 -0
  32. dolphin/io/_writers.py +479 -0
  33. dolphin/log-config.json +45 -0
  34. dolphin/masking.py +147 -0
  35. dolphin/phase_link/__init__.py +9 -0
  36. dolphin/phase_link/_compress.py +45 -0
  37. dolphin/phase_link/_core.jl +19 -0
  38. dolphin/phase_link/_core.py +507 -0
  39. dolphin/phase_link/_eigenvalues.py +205 -0
  40. dolphin/phase_link/_ps_filling.py +153 -0
  41. dolphin/phase_link/covariance.py +195 -0
  42. dolphin/phase_link/metrics.py +95 -0
  43. dolphin/phase_link/simulate.py +433 -0
  44. dolphin/ps.py +489 -0
  45. dolphin/py.typed +0 -0
  46. dolphin/shp/__init__.py +117 -0
  47. dolphin/shp/_common.py +79 -0
  48. dolphin/shp/_glrt.py +206 -0
  49. dolphin/shp/_ks.py +243 -0
  50. dolphin/shp/glrt_cutoffs.csv +1201 -0
  51. dolphin/similarity.py +325 -0
  52. dolphin/stack.py +466 -0
  53. dolphin/stitching.py +631 -0
  54. dolphin/timeseries.py +926 -0
  55. dolphin/unwrap/__init__.py +5 -0
  56. dolphin/unwrap/_constants.py +11 -0
  57. dolphin/unwrap/_isce3.py +153 -0
  58. dolphin/unwrap/_post_process.py +127 -0
  59. dolphin/unwrap/_snaphu_py.py +227 -0
  60. dolphin/unwrap/_tophu.py +189 -0
  61. dolphin/unwrap/_unwrap.py +441 -0
  62. dolphin/unwrap/_unwrap_3d.py +108 -0
  63. dolphin/unwrap/_utils.py +162 -0
  64. dolphin/utils.py +685 -0
  65. dolphin/workflows/__init__.py +4 -0
  66. dolphin/workflows/_cli_config.py +407 -0
  67. dolphin/workflows/_cli_run.py +70 -0
  68. dolphin/workflows/_utils.py +32 -0
  69. dolphin/workflows/config/__init__.py +9 -0
  70. dolphin/workflows/config/_common.py +436 -0
  71. dolphin/workflows/config/_displacement.py +237 -0
  72. dolphin/workflows/config/_enums.py +33 -0
  73. dolphin/workflows/config/_ps.py +77 -0
  74. dolphin/workflows/config/_unwrap_options.py +265 -0
  75. dolphin/workflows/config/_unwrapping.py +74 -0
  76. dolphin/workflows/config/_yaml_model.py +220 -0
  77. dolphin/workflows/displacement.py +358 -0
  78. dolphin/workflows/ps.py +120 -0
  79. dolphin/workflows/sequential.py +183 -0
  80. dolphin/workflows/single.py +422 -0
  81. dolphin/workflows/stitching_bursts.py +168 -0
  82. dolphin/workflows/unwrapping.py +118 -0
  83. dolphin/workflows/wrapped_phase.py +386 -0
  84. dolphin-0.22.0.dist-info/LICENSE +236 -0
  85. dolphin-0.22.0.dist-info/METADATA +149 -0
  86. dolphin-0.22.0.dist-info/RECORD +89 -0
  87. dolphin-0.22.0.dist-info/WHEEL +5 -0
  88. dolphin-0.22.0.dist-info/entry_points.txt +2 -0
  89. dolphin-0.22.0.dist-info/top_level.txt +1 -0
dolphin/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ # flake8: noqa
2
+ # version.py is autogenerated by setuptools_scm
3
+ from dolphin._version import version as __version__
4
+
5
+ from ._log import *
6
+ from ._show_versions import *
7
+ from ._types import *
8
+ from .goldstein import goldstein
9
+ from .interpolation import interpolate
dolphin/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Main module to provide command line interface to the workflows."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ # https://docs.python.org/3/library/__main__.html#packaging-considerations
8
+ # allows `python -m dolphin` to work
9
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from dolphin.workflows import CallFunc
6
+
7
+ if TYPE_CHECKING:
8
+ _SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
9
+ else:
10
+ _SubparserType = Any
11
+
12
+
13
+ def get_parser(subparser=None, subcommand_name="timeseries") -> argparse.ArgumentParser:
14
+ """Set up the command line interface."""
15
+ metadata = {
16
+ "description": "Create a configuration file for a displacement workflow.",
17
+ "formatter_class": argparse.ArgumentDefaultsHelpFormatter,
18
+ # https://docs.python.org/3/library/argparse.html#fromfile-prefix-chars
19
+ "fromfile_prefix_chars": "@",
20
+ }
21
+ if subparser:
22
+ # Used by the subparser to make a nested command line interface
23
+ parser = subparser.add_parser(subcommand_name, **metadata)
24
+ else:
25
+ parser = argparse.ArgumentParser(**metadata) # type: ignore[arg-type]
26
+
27
+ # parser._action_groups.pop()
28
+ parser.add_argument(
29
+ "-o",
30
+ "--output-dir",
31
+ default=Path(),
32
+ help="Path to output directory to store results",
33
+ )
34
+ parser.add_argument(
35
+ "--unwrapped-paths",
36
+ nargs=argparse.ZERO_OR_MORE,
37
+ help=(
38
+ "List the paths of all unwrapped interferograms. Can pass a "
39
+ "newline delimited file with @ifg_filelist.txt"
40
+ ),
41
+ )
42
+ parser.add_argument(
43
+ "--conncomp-paths",
44
+ nargs=argparse.ZERO_OR_MORE,
45
+ help=(
46
+ "List the paths of all connected component files. Can pass a "
47
+ "newline delimited file with @conncomp_filelist.txt"
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "--corr-paths",
52
+ nargs=argparse.ZERO_OR_MORE,
53
+ help=(
54
+ "List the paths of all correlation files. Can pass a newline delimited"
55
+ " file with @cor_filelist.txt"
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--condition-file",
60
+ help=(
61
+ "A file with the same size as each raster, like amplitude dispersion or"
62
+ "temporal coherence to find reference point. default: amplitude dispersion"
63
+ ),
64
+ )
65
+ parser.add_argument(
66
+ "--condition",
67
+ type=CallFunc,
68
+ default=CallFunc.MIN,
69
+ help=(
70
+ "A condition to apply to condition file to find the reference point"
71
+ "Options are [min, max]. default=min"
72
+ ),
73
+ )
74
+ parser.add_argument(
75
+ "--num-threads",
76
+ type=int,
77
+ default=5,
78
+ help="Number of threads for the inversion",
79
+ )
80
+ parser.add_argument(
81
+ "--run-velocity",
82
+ action="store_true",
83
+ help="Run the velocity estimation from the phase time series",
84
+ )
85
+ parser.add_argument(
86
+ "--reference-point",
87
+ type=int,
88
+ nargs=2,
89
+ metavar=("ROW", "COL"),
90
+ default=(-1, -1),
91
+ help=(
92
+ "Reference point (row, col) used if performing a time series inversion. "
93
+ "If not provided, a point will be selected from a consistent connected "
94
+ "component with low amplitude dispersion or high temporal coherence."
95
+ ),
96
+ )
97
+ parser.add_argument(
98
+ "--correlation-threshold",
99
+ type=range_limited_float_type,
100
+ default=0.2,
101
+ metavar="[0-1]",
102
+ help="Pixels with correlation below this value will be masked out.",
103
+ )
104
+
105
+ parser.set_defaults(run_func=_run_timeseries)
106
+
107
+ return parser
108
+
109
+
110
+ def range_limited_float_type(arg):
111
+ """Type function for argparse - a float within some predefined bounds."""
112
+ try:
113
+ f = float(arg)
114
+ except ValueError as err:
115
+ raise argparse.ArgumentTypeError("Must be a floating point number") from err
116
+ if f < 0 or f > 1:
117
+ raise argparse.ArgumentTypeError(
118
+ "Argument must be < " + str(1) + "and > " + str(0)
119
+ )
120
+ return f
121
+
122
+
123
+ def _run_timeseries(*args, **kwargs):
124
+ """Run `dolphin.timeseries.run`.
125
+
126
+ Wrapper for the dolphin.timeseries to invert and create velocity.
127
+ """
128
+ from dolphin import timeseries
129
+
130
+ return timeseries.run(*args, **kwargs)
131
+
132
+
133
+ def main(args=None):
134
+ """Get the command line arguments for timeseries inversion."""
135
+ from dolphin import timeseries
136
+
137
+ parser = get_parser()
138
+ parsed_args = parser.parse_args(args)
139
+
140
+ timeseries.run(**vars(parsed_args))
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
dolphin/_cli_unwrap.py ADDED
@@ -0,0 +1,150 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from dolphin.workflows.config import UnwrapMethod
6
+
7
+ if TYPE_CHECKING:
8
+ _SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
9
+ else:
10
+ _SubparserType = Any
11
+
12
+
13
+ def get_parser(subparser=None, subcommand_name="unwrap") -> argparse.ArgumentParser:
14
+ """Set up the command line interface."""
15
+ metadata = {
16
+ "description": "Create a configuration file for a displacement workflow.",
17
+ "formatter_class": argparse.ArgumentDefaultsHelpFormatter,
18
+ # https://docs.python.org/3/library/argparse.html#fromfile-prefix-chars
19
+ "fromfile_prefix_chars": "@",
20
+ }
21
+ if subparser:
22
+ # Used by the subparser to make a nested command line interface
23
+ parser = subparser.add_parser(subcommand_name, **metadata)
24
+ else:
25
+ parser = argparse.ArgumentParser(**metadata) # type: ignore[arg-type]
26
+
27
+ # parser._action_groups.pop()
28
+ parser.add_argument(
29
+ "-o",
30
+ "--output-path",
31
+ default=Path(),
32
+ help="Path to output directory to store results",
33
+ )
34
+ # Get Inputs from the command line
35
+ inputs = parser.add_argument_group("Input options")
36
+ inputs.add_argument(
37
+ "--ifg-filenames",
38
+ nargs=argparse.ZERO_OR_MORE,
39
+ help=(
40
+ "List the paths of all ifg files to include. Can pass a newline delimited"
41
+ " file with @ifg_filelist.txt"
42
+ ),
43
+ )
44
+ inputs.add_argument(
45
+ "--cor-filenames",
46
+ nargs=argparse.ZERO_OR_MORE,
47
+ help=(
48
+ "List the paths of all ifg files to include. Can pass a newline delimited"
49
+ " file with @cor_filelist.txt"
50
+ ),
51
+ )
52
+ inputs.add_argument(
53
+ "--mask-filename",
54
+ help=(
55
+ "Path to Byte mask file used to ignore low correlation/bad data (e.g water"
56
+ " mask). Convention is 0 for no data/invalid, and 1 for good data."
57
+ ),
58
+ )
59
+ inputs.add_argument(
60
+ "--temp-coh-filename",
61
+ help="Path to temporal coherence file from phase linking",
62
+ )
63
+ parser.add_argument(
64
+ "--nlooks",
65
+ type=int,
66
+ help="Effective number of looks used to form correlation",
67
+ )
68
+
69
+ parser.add_argument(
70
+ "--max-jobs",
71
+ type=int,
72
+ default=1,
73
+ help="Number of parallel files to unwrap",
74
+ )
75
+
76
+ algorithm_opts = parser.add_argument_group("Algorithm options")
77
+ algorithm_opts.add_argument(
78
+ "--unwrap-method",
79
+ type=UnwrapMethod,
80
+ choices=[m.value for m in UnwrapMethod],
81
+ default=UnwrapMethod.SNAPHU.value,
82
+ help="Choice of unwrapping algorithm to use.",
83
+ )
84
+ algorithm_opts.add_argument(
85
+ "--run-goldstein",
86
+ action="store_true",
87
+ help="Run Goldstein filter before unwrapping.",
88
+ )
89
+ algorithm_opts.add_argument(
90
+ "--run-interpolation",
91
+ action="store_true",
92
+ help="Run interpolation before unwrapping.",
93
+ )
94
+
95
+ spurt_opts = parser.add_argument_group("Spurt options")
96
+ spurt_opts.add_argument(
97
+ "--temp-coh-threshold",
98
+ type=float,
99
+ help="Cutoff on temporal_coherence raster to choose pixels for unwrapping.",
100
+ )
101
+
102
+ tophu_opts = parser.add_argument_group("Tophu options")
103
+ # Add ability for downsampling/tiling with tophu
104
+ tophu_opts.add_argument(
105
+ "--ntiles",
106
+ type=int,
107
+ nargs=2,
108
+ metavar=("ROW_TILES", "COL_TILES"),
109
+ default=(1, 1),
110
+ help=(
111
+ "(using tophu) Split the interferograms into this number of tiles along the"
112
+ " (row, col) axis."
113
+ ),
114
+ )
115
+ tophu_opts.add_argument(
116
+ "--downsample-factor",
117
+ type=int,
118
+ nargs=2,
119
+ default=(1, 1),
120
+ help=(
121
+ "(using tophu) Downsample the interferograms by this factor "
122
+ " during multiresolution unwrapping."
123
+ ),
124
+ )
125
+ parser.set_defaults(run_func=_run_unwrap)
126
+
127
+ return parser
128
+
129
+
130
+ def _run_unwrap(*args, **kwargs):
131
+ """Run `dolphin.unwrap.run`.
132
+
133
+ Wrapper for the dolphin.unwrap to delay import time.
134
+ """
135
+ from dolphin import unwrap
136
+
137
+ return unwrap.run(*args, **kwargs)
138
+
139
+
140
+ def main(args=None):
141
+ """Get the command line arguments and unwrap files."""
142
+ from dolphin import unwrap
143
+
144
+ parser = get_parser()
145
+ parsed_args = parser.parse_args(args)
146
+ unwrap.run(**vars(parsed_args))
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
dolphin/_decorators.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ __all__ = [
13
+ "atomic_output",
14
+ ]
15
+
16
+
17
+ def atomic_output(
18
+ output_arg: str = "output_file",
19
+ is_dir: bool = False,
20
+ use_tmp: bool = False,
21
+ overwrite: bool = False,
22
+ ) -> Callable:
23
+ """Use a temporary file/directory for the `output_arg` until the function finishes.
24
+
25
+ Decorator is used on a function which writes to an output file/directory in blocks.
26
+ If the function were interrupted, the file/directory would be partially complete.
27
+
28
+ This decorator replaces the final output name with a temp file/dir, and then
29
+ renames the temp file/dir to the final name after the function finishes.
30
+
31
+ Note that when `is_dir=True`, `output_arg` can be a directory (if multiple files
32
+ are being written to). In this case, the entire directory is temporary, and
33
+ renamed after the function finishes.
34
+
35
+ Parameters
36
+ ----------
37
+ output_arg : str, optional
38
+ The name of the argument to replace, by default 'output_file'
39
+ is_dir : bool, default = False
40
+ If `True`, the output argument is a directory, not a file
41
+ use_tmp : bool, default = False
42
+ If `False`, uses the parent directory of the desired output, with
43
+ a random suffix added to the name to distinguish from actual output.
44
+ If `True`, uses the `/tmp` directory (or wherever the default is
45
+ for the `tempfile` module).
46
+ overwrite : bool, default = False
47
+ Overwrite an existing file.
48
+ If `False` raises `FileExistsError` if the file already exists.
49
+
50
+ Returns
51
+ -------
52
+ Callable
53
+ The decorated function
54
+
55
+ Raises
56
+ ------
57
+ FileExistsError
58
+ if the file for `output_arg` already exists (if out_dir=False), or
59
+ if the directory at `output_arg` exists and is non-empty.
60
+
61
+ Notes
62
+ -----
63
+ The output at `output_arg` *must not* exist already, or the decorator will error
64
+ (though if `is_dir=True`, it is allowed to be an empty directory).
65
+ The function being decorated *must* be called with keyword args for `output_arg`.
66
+
67
+ """
68
+
69
+ def decorator(func: Callable) -> Callable:
70
+ @functools.wraps(func)
71
+ def wrapper(*args, **kwargs) -> Any:
72
+ # Extract the output file path
73
+ if kwargs.get(output_arg):
74
+ final_out_name = kwargs[output_arg]
75
+ else:
76
+ msg = (
77
+ f"Argument {output_arg} not passed to function {func.__name__}:"
78
+ f" {kwargs}"
79
+ )
80
+ raise FileExistsError(msg)
81
+
82
+ final_path = Path(final_out_name)
83
+ # Make sure the desired final output doesn't already exist
84
+ _raise_if_exists(final_path, is_dir=is_dir, overwrite=overwrite)
85
+ # None means that tempfile will use /tmp
86
+ tmp_dir = final_path.parent if not use_tmp else None
87
+
88
+ # Make the tempfile start the same as the desired output
89
+ prefix = final_path.name
90
+ if is_dir:
91
+ # Create a temporary directory
92
+ temp_path = tempfile.mkdtemp(dir=tmp_dir, prefix=prefix)
93
+ else:
94
+ # Create a temporary file
95
+ suffix = final_path.suffix
96
+ _, temp_path = tempfile.mkstemp(
97
+ dir=tmp_dir, prefix=prefix, suffix=suffix
98
+ )
99
+ logger.debug("Writing to temp file %s instead of %s", temp_path, final_path)
100
+
101
+ try:
102
+ # Replace the output file path with the temp file
103
+ # It would be like this if we only allows keyword:
104
+ kwargs[output_arg] = temp_path
105
+ # Execute the original function
106
+ result = func(*args, **kwargs)
107
+ # Move the temp file to the final location
108
+ logger.debug("Moving %s to %s", temp_path, final_path)
109
+ shutil.move(temp_path, final_path)
110
+
111
+ return result
112
+ finally:
113
+ logger.debug("Cleaning up temp file %s", temp_path)
114
+ # Different cleanup is needed
115
+ if is_dir:
116
+ shutil.rmtree(temp_path, ignore_errors=True)
117
+ else:
118
+ Path(temp_path).unlink(missing_ok=True)
119
+
120
+ return wrapper
121
+
122
+ return decorator
123
+
124
+
125
+ def _raise_if_exists(final_path: Path, is_dir: bool, overwrite: bool):
126
+ msg = f"{final_path} already exists"
127
+ if final_path.exists():
128
+ logger.debug(f"{final_path} already exists")
129
+ if overwrite:
130
+ if final_path.is_dir():
131
+ shutil.rmtree(final_path)
132
+ else:
133
+ final_path.unlink()
134
+ return
135
+
136
+ if is_dir and final_path.is_dir():
137
+ # We can work with an empty directory
138
+ try:
139
+ final_path.rmdir()
140
+ except OSError as e:
141
+ err_msg = str(e)
142
+ if "Directory not empty" in err_msg:
143
+ raise FileExistsError(msg) from e
144
+ else:
145
+ # Some other error we don't know
146
+ raise
147
+ else:
148
+ raise FileExistsError(msg)
dolphin/_log.py ADDED
@@ -0,0 +1,137 @@
1
+ import json
2
+ import logging
3
+ import logging.config
4
+ import os
5
+ import sys
6
+ import time
7
+ from collections.abc import Callable
8
+ from datetime import datetime, timezone
9
+ from functools import wraps
10
+ from pathlib import Path
11
+
12
+ from dolphin._types import P, PathOrStr, T
13
+
14
+ LOG_RECORD_BUILTIN_ATTRS = {
15
+ "args",
16
+ "asctime",
17
+ "created",
18
+ "exc_info",
19
+ "exc_text",
20
+ "filename",
21
+ "funcName",
22
+ "levelname",
23
+ "levelno",
24
+ "lineno",
25
+ "module",
26
+ "msecs",
27
+ "message",
28
+ "msg",
29
+ "name",
30
+ "pathname",
31
+ "process",
32
+ "processName",
33
+ "relativeCreated",
34
+ "stack_info",
35
+ "thread",
36
+ "threadName",
37
+ "taskName",
38
+ }
39
+ __all__ = ["log_runtime"]
40
+
41
+
42
+ def setup_logging(debug: bool = False, filename: PathOrStr | None = None):
43
+ config_file = Path(__file__).parent / Path("log-config.json")
44
+ with open(config_file) as f_in:
45
+ config = json.load(f_in)
46
+
47
+ if debug:
48
+ config["loggers"]["dolphin"]["level"] = "DEBUG"
49
+
50
+ if filename:
51
+ config["loggers"]["dolphin"]["handlers"].append("file")
52
+ config["handlers"]["file"]["filename"] = os.fspath(filename)
53
+ Path(filename).parent.mkdir(exist_ok=True, parents=True)
54
+
55
+ if "filename" not in config["handlers"]["file"]:
56
+ # We never passed in a filename: don't log to a file
57
+ config["handlers"].pop("file")
58
+
59
+ logging.config.dictConfig(config)
60
+ # Temp work around for tqdm on py312
61
+ if sys.version_info.major == 3 and sys.version_info.minor == 12:
62
+ os.environ["TQDM_DISABLE"] = "1"
63
+
64
+
65
+ def log_runtime(f: Callable[P, T]) -> Callable[P, T]:
66
+ """Decorate a function to time how long it takes to run.
67
+
68
+ Usage
69
+ -----
70
+ @log_runtime
71
+ def test_func():
72
+ return 2 + 4
73
+ """
74
+ logger = logging.getLogger(__name__)
75
+
76
+ @wraps(f)
77
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
78
+ t1 = time.time()
79
+
80
+ result = f(*args, **kwargs)
81
+
82
+ t2 = time.time()
83
+ elapsed_seconds = t2 - t1
84
+ elapsed_minutes = elapsed_seconds / 60.0
85
+ time_string = (
86
+ f"Total elapsed time for {f.__module__}.{f.__name__} : "
87
+ f"{elapsed_minutes:.2f} minutes ({elapsed_seconds:.2f} seconds)"
88
+ )
89
+
90
+ logger.info(time_string)
91
+
92
+ return result
93
+
94
+ return wrapper
95
+
96
+
97
+ class JSONFormatter(logging.Formatter):
98
+ def __init__(
99
+ self,
100
+ *,
101
+ fmt_keys: dict[str, str] | None = None,
102
+ ):
103
+ super().__init__()
104
+ self.fmt_keys = fmt_keys if fmt_keys is not None else {}
105
+
106
+ def format(self, record: logging.LogRecord) -> str:
107
+ message = self._prepare_log_dict(record)
108
+ return json.dumps(message, default=str)
109
+
110
+ def _prepare_log_dict(self, record: logging.LogRecord):
111
+ always_fields = {
112
+ "message": record.getMessage(),
113
+ "timestamp": datetime.fromtimestamp(
114
+ record.created, tz=timezone.utc
115
+ ).isoformat(),
116
+ }
117
+ if record.exc_info is not None:
118
+ always_fields["exc_info"] = self.formatException(record.exc_info)
119
+
120
+ if record.stack_info is not None:
121
+ always_fields["stack_info"] = self.formatStack(record.stack_info)
122
+
123
+ message = {
124
+ key: (
125
+ msg_val
126
+ if (msg_val := always_fields.pop(val, None)) is not None
127
+ else getattr(record, val)
128
+ )
129
+ for key, val in self.fmt_keys.items()
130
+ }
131
+ message.update(always_fields)
132
+
133
+ for key, val in record.__dict__.items():
134
+ if key not in LOG_RECORD_BUILTIN_ATTRS:
135
+ message[key] = val
136
+
137
+ return message