lunar-toolkit 0.1.0.dev2__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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Kaguya Terrain Camera Level 2B0: ODE file selection, download and ISIS import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from ..constants import DEFAULT_PROJECTION, GEOTIFF_OPTIONS
|
|
16
|
+
from ..downloader import download_file, url_filename
|
|
17
|
+
from ..isis import isd_generate, kaguyatc2isis, map_project, spiceinit
|
|
18
|
+
from ..tools import is_dry_run, reuse_existing, run
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from ..ode import OdeClient
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Level 2B0 TC product IDs, e.g. TC1W2B0_01_02936N034E0938 (W = stereo, S = mono).
|
|
28
|
+
TC_ID_RE = re.compile(r"^TC[12][WS]2B0_\d{2}_\d{5}[NS]\d{3}E\d{4}$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
# kaguyatc2isis: keep every DN (ASP turns the special-pixel ranges off).
|
|
31
|
+
KAGUYATC2ISIS_PARAMS: dict[str, Any] = {
|
|
32
|
+
"setnullrange": False,
|
|
33
|
+
"sethrsrange": False,
|
|
34
|
+
"sethisrange": False,
|
|
35
|
+
"setlrsrange": False,
|
|
36
|
+
"setlisrange": False,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# spiceinit: reconstructed CK, smithed / reconstructed SPK (ASP example).
|
|
40
|
+
# ``attach`` is dropped with ``web=true``, which always attaches (ISIS rejects both).
|
|
41
|
+
SPICEINIT_PARAMS: dict[str, Any] = {
|
|
42
|
+
"attach": True,
|
|
43
|
+
"cksmithed": False,
|
|
44
|
+
"ckrecon": True,
|
|
45
|
+
"ckpredicted": False,
|
|
46
|
+
"cknadir": False,
|
|
47
|
+
"spkrecon": True,
|
|
48
|
+
"spkpredicted": False,
|
|
49
|
+
"shape": "system",
|
|
50
|
+
"startpad": 0.0,
|
|
51
|
+
"endpad": 0.0,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_IMAGE_POINTER_RE = re.compile(r'^\s*\^IMAGE\s*=\s*\(?\s*"([^"]+)"', re.MULTILINE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class TcProduct:
|
|
59
|
+
"""Data and label URLs of one Level 2B0 TC product."""
|
|
60
|
+
|
|
61
|
+
product_id: str
|
|
62
|
+
image_url: str
|
|
63
|
+
label_url: str
|
|
64
|
+
image_kbytes: float | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_stereo(self) -> bool:
|
|
68
|
+
return self.product_id[3] == "W"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def sensor(self) -> str:
|
|
72
|
+
"""``"TC1"`` or ``"TC2"``."""
|
|
73
|
+
return self.product_id[:3]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class TcOutputs:
|
|
78
|
+
"""Files written for one product (None for skipped steps)."""
|
|
79
|
+
|
|
80
|
+
product_id: str
|
|
81
|
+
cube: Path
|
|
82
|
+
camera: Path | None = None
|
|
83
|
+
map_cube: Path | None = None
|
|
84
|
+
geotiff: Path | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_tc_product_id(product_id: str) -> bool:
|
|
88
|
+
"""True for a Level 2B0 TC product ID that ``kaguyatc2isis`` can import."""
|
|
89
|
+
return bool(TC_ID_RE.match(product_id.strip()))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def select_tc_files(files: Iterable[dict], product_id: str | None = None) -> TcProduct | None:
|
|
93
|
+
"""Pick the data and label files of a TC product from its ODE ``files`` list.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
files : iterable of dict
|
|
98
|
+
ODE product-file dicts (``FileName``, ``Type``, ``URL``, ``KBytes``).
|
|
99
|
+
product_id : str, optional
|
|
100
|
+
Product ID; derived from the data file name when omitted.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
TcProduct or None
|
|
105
|
+
None when the list lacks a ``.img`` / ``.img.gz`` data file or a
|
|
106
|
+
``.lbl`` label. An uncompressed ``.img`` is preferred over ``.img.gz``.
|
|
107
|
+
"""
|
|
108
|
+
image: dict | None = None
|
|
109
|
+
label: dict | None = None
|
|
110
|
+
for f in files or []:
|
|
111
|
+
if str(f.get("Type", "")).lower() != "product" or not f.get("URL"):
|
|
112
|
+
continue
|
|
113
|
+
name = str(f.get("FileName") or url_filename(f["URL"])).upper()
|
|
114
|
+
if name.endswith(".LBL") and label is None:
|
|
115
|
+
label = f
|
|
116
|
+
elif name.endswith(".IMG") or (name.endswith(".IMG.GZ") and image is None):
|
|
117
|
+
image = f
|
|
118
|
+
if image is None or label is None:
|
|
119
|
+
return None
|
|
120
|
+
pid = product_id or url_filename(image["URL"]).split(".")[0]
|
|
121
|
+
kb = image.get("KBytes")
|
|
122
|
+
try:
|
|
123
|
+
kbytes = float(kb) if kb is not None else None
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
kbytes = None
|
|
126
|
+
return TcProduct(pid.upper(), image["URL"], label["URL"], kbytes)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def tc_products(df: pd.DataFrame) -> list[TcProduct]:
|
|
130
|
+
"""TC products with downloadable data + label files from an ODE search.
|
|
131
|
+
|
|
132
|
+
Rows that are not Level 2B0 products (DTMs, ortho maps) or lack either
|
|
133
|
+
file are skipped with a warning.
|
|
134
|
+
"""
|
|
135
|
+
out: list[TcProduct] = []
|
|
136
|
+
pids = df["product_id"] if "product_id" in df else [None] * len(df)
|
|
137
|
+
for pid, files in zip(pids, df.get("files", [])):
|
|
138
|
+
if pid and not is_tc_product_id(pid):
|
|
139
|
+
log.warning("Skipping %s: not a Level 2B0 TC product", pid)
|
|
140
|
+
continue
|
|
141
|
+
product = select_tc_files(files, pid)
|
|
142
|
+
if product is None:
|
|
143
|
+
log.warning("Skipping %s: no .img/.lbl product files", pid)
|
|
144
|
+
continue
|
|
145
|
+
out.append(product)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def lookup_products(
|
|
150
|
+
product_ids: Iterable[str],
|
|
151
|
+
client: OdeClient | None = None,
|
|
152
|
+
) -> pd.DataFrame:
|
|
153
|
+
"""Query ODE by product ID and return the observation DataFrame (see :meth:`OdeClient.lookup`)."""
|
|
154
|
+
from ..ode import OdeClient
|
|
155
|
+
|
|
156
|
+
return (client or OdeClient()).lookup(product_ids, instrument_label="Kaguya TC")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def image_pointer(label: Path) -> str | None:
|
|
160
|
+
"""File name in the label's ``^IMAGE`` pointer (None if attached / absent)."""
|
|
161
|
+
m = _IMAGE_POINTER_RE.search(Path(label).read_text(encoding="latin-1", errors="replace"))
|
|
162
|
+
return m.group(1) if m else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def decompress(gz: Path, out: Path | None = None, remove: bool = False) -> Path:
|
|
166
|
+
"""Gunzip *gz* to *out* (default: name without ``.gz``) via a ``.part`` file."""
|
|
167
|
+
gz = Path(gz)
|
|
168
|
+
out = Path(out) if out else gz.with_suffix("")
|
|
169
|
+
tmp = out.with_name(out.name + ".part")
|
|
170
|
+
log.info("Decompressing %s", gz.name)
|
|
171
|
+
with gzip.open(gz, "rb") as src, open(tmp, "wb") as dst:
|
|
172
|
+
shutil.copyfileobj(src, dst, 8 << 20)
|
|
173
|
+
tmp.replace(out)
|
|
174
|
+
if remove:
|
|
175
|
+
gz.unlink()
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def download_product(
|
|
180
|
+
product: TcProduct,
|
|
181
|
+
output_dir: Path,
|
|
182
|
+
force: bool = False,
|
|
183
|
+
keep_gz: bool = False,
|
|
184
|
+
show_progress: bool = True,
|
|
185
|
+
) -> Path:
|
|
186
|
+
"""Download (and gunzip) one product's label and image into *output_dir*.
|
|
187
|
+
|
|
188
|
+
The image is written under the name in the label's ``^IMAGE`` pointer so
|
|
189
|
+
``kaguyatc2isis`` finds it next to the label. Existing files are reused
|
|
190
|
+
unless *force*. Returns the label path.
|
|
191
|
+
"""
|
|
192
|
+
output_dir = Path(output_dir)
|
|
193
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
label = output_dir / url_filename(product.label_url)
|
|
195
|
+
if not reuse_existing(label, force):
|
|
196
|
+
download_file(product.label_url, label, show_progress=show_progress)
|
|
197
|
+
img = output_dir / (image_pointer(label) or f"{product.product_id}.img")
|
|
198
|
+
if reuse_existing(img, force):
|
|
199
|
+
return label
|
|
200
|
+
remote = output_dir / url_filename(product.image_url)
|
|
201
|
+
if not reuse_existing(remote, force):
|
|
202
|
+
download_file(product.image_url, remote, show_progress=show_progress)
|
|
203
|
+
if remote.name.lower().endswith(".gz"):
|
|
204
|
+
decompress(remote, img, remove=not keep_gz)
|
|
205
|
+
elif remote != img:
|
|
206
|
+
remote.replace(img)
|
|
207
|
+
return label
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TcProcessor:
|
|
211
|
+
"""Download and import Kaguya TC Level 2B0 products into ISIS.
|
|
212
|
+
|
|
213
|
+
Each product ends up in *output_dir* as ``<id>.cub`` (spiceinit'ed),
|
|
214
|
+
``<id>.json`` (CSM camera, when *csm*) and, when *mapproject*,
|
|
215
|
+
``<id>.tif`` (:data:`~lunar_toolkit.constants.GEOTIFF_OPTIONS`), the
|
|
216
|
+
same flat layout and resume / *keep* / *force* behaviour as
|
|
217
|
+
:class:`~lunar_toolkit.lroc.nac.NacProcessor`. Steps whose output
|
|
218
|
+
exists are skipped unless *force*.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
output_dir : Path
|
|
223
|
+
Where labels, images, cubes and cameras are written.
|
|
224
|
+
web : bool
|
|
225
|
+
Use the USGS SPICE web service in ``spiceinit`` (no local Kaguya
|
|
226
|
+
kernels needed). Set False when ``$ISISDATA/kaguya`` is installed.
|
|
227
|
+
csm : bool
|
|
228
|
+
Write a CSM camera with ``isd_generate``.
|
|
229
|
+
only_naif_spice : bool, optional
|
|
230
|
+
Pass ``--only_naif_spice`` to ``isd_generate`` (ASP's recipe; needs
|
|
231
|
+
local kernels). Default: ``not web``.
|
|
232
|
+
mapproject : bool
|
|
233
|
+
Also ``cam2map`` the cube and write a GeoTIFF.
|
|
234
|
+
projection : str
|
|
235
|
+
ISIS projection for ``mosrange`` (default: equirectangular).
|
|
236
|
+
resolution : float, optional
|
|
237
|
+
Map resolution in m/pixel (default: ``mosrange``'s estimate).
|
|
238
|
+
keep : bool
|
|
239
|
+
Keep the PDS ``.img`` and the map-projected cube after use.
|
|
240
|
+
force : bool
|
|
241
|
+
Redo every step even when its output exists.
|
|
242
|
+
kaguyatc2isis_params, spiceinit_params : dict, optional
|
|
243
|
+
Overrides merged into :data:`KAGUYATC2ISIS_PARAMS` /
|
|
244
|
+
:data:`SPICEINIT_PARAMS`.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
output_dir: Path = Path("kaguya"),
|
|
250
|
+
*,
|
|
251
|
+
web: bool = True,
|
|
252
|
+
csm: bool = True,
|
|
253
|
+
only_naif_spice: bool | None = None,
|
|
254
|
+
mapproject: bool = False,
|
|
255
|
+
projection: str = DEFAULT_PROJECTION,
|
|
256
|
+
resolution: float | None = None,
|
|
257
|
+
keep: bool = False,
|
|
258
|
+
force: bool = False,
|
|
259
|
+
kaguyatc2isis_params: dict[str, Any] | None = None,
|
|
260
|
+
spiceinit_params: dict[str, Any] | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
self.output_dir = Path(output_dir)
|
|
263
|
+
self.web = web
|
|
264
|
+
self.csm = csm
|
|
265
|
+
self.only_naif_spice = (not web) if only_naif_spice is None else only_naif_spice
|
|
266
|
+
self.mapproject = mapproject
|
|
267
|
+
self.projection = projection
|
|
268
|
+
self.resolution = resolution
|
|
269
|
+
self.keep = keep
|
|
270
|
+
self.force = force
|
|
271
|
+
self.kaguyatc2isis_params = {**KAGUYATC2ISIS_PARAMS, **(kaguyatc2isis_params or {})}
|
|
272
|
+
self.spiceinit_params = {**SPICEINIT_PARAMS, **(spiceinit_params or {})}
|
|
273
|
+
|
|
274
|
+
def process(self, source: TcProduct | str | Path) -> TcOutputs:
|
|
275
|
+
"""Download (if needed) and import one product.
|
|
276
|
+
|
|
277
|
+
*source* is a :class:`TcProduct`, a local ``.lbl`` path or a product
|
|
278
|
+
ID (looked up on ODE).
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(source, TcProduct):
|
|
281
|
+
label = download_product(source, self.output_dir, force=self.force)
|
|
282
|
+
elif Path(source).suffix.lower() == ".lbl":
|
|
283
|
+
label = Path(source)
|
|
284
|
+
else:
|
|
285
|
+
products = tc_products(lookup_products([str(source)]))
|
|
286
|
+
if not products:
|
|
287
|
+
raise ValueError(f"No Kaguya TC Level 2B0 product {source!r} on ODE")
|
|
288
|
+
label = download_product(products[0], self.output_dir, force=self.force)
|
|
289
|
+
return self.import_label(label)
|
|
290
|
+
|
|
291
|
+
def process_batch(
|
|
292
|
+
self,
|
|
293
|
+
sources: Sequence[TcProduct | str | Path],
|
|
294
|
+
max_workers: int = 2,
|
|
295
|
+
) -> list[TcOutputs | None]:
|
|
296
|
+
"""Process several products in parallel (None for failures)."""
|
|
297
|
+
results: list[TcOutputs | None] = [None] * len(sources)
|
|
298
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
299
|
+
futures = {pool.submit(self.process, s): i for i, s in enumerate(sources)}
|
|
300
|
+
for future in as_completed(futures):
|
|
301
|
+
i = futures[future]
|
|
302
|
+
try:
|
|
303
|
+
results[i] = future.result()
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
log.error("Failed %s: %s", _source_name(sources[i]), exc)
|
|
306
|
+
return results
|
|
307
|
+
|
|
308
|
+
def import_label(self, label: Path) -> TcOutputs:
|
|
309
|
+
"""Run the ISIS chain on a downloaded PDS3 label (image beside it)."""
|
|
310
|
+
label = Path(label)
|
|
311
|
+
pid = label.name.split(".")[0].upper()
|
|
312
|
+
if not is_dry_run():
|
|
313
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
cube = self.output_dir / f"{pid}.cub"
|
|
315
|
+
out = TcOutputs(pid, cube)
|
|
316
|
+
|
|
317
|
+
if not reuse_existing(cube, self.force):
|
|
318
|
+
try:
|
|
319
|
+
kaguyatc2isis(label, cube, **self.kaguyatc2isis_params)
|
|
320
|
+
spiceinit(cube, web=self.web, **self._spiceinit_params())
|
|
321
|
+
except Exception:
|
|
322
|
+
cube.unlink(missing_ok=True) # never reuse a cube without SPICE
|
|
323
|
+
raise
|
|
324
|
+
if not self.keep and not is_dry_run():
|
|
325
|
+
self._remove_image(label)
|
|
326
|
+
|
|
327
|
+
if self.csm:
|
|
328
|
+
out.camera = self.output_dir / f"{pid}.json"
|
|
329
|
+
if not reuse_existing(out.camera, self.force):
|
|
330
|
+
isd_generate(cube, out.camera, only_naif_spice=self.only_naif_spice)
|
|
331
|
+
|
|
332
|
+
if self.mapproject:
|
|
333
|
+
out.geotiff = self.output_dir / f"{pid}.tif"
|
|
334
|
+
map_cube = self.output_dir / f"{pid}.map.cub"
|
|
335
|
+
if not reuse_existing(out.geotiff, self.force):
|
|
336
|
+
self._mapproject(cube, map_cube, out.geotiff)
|
|
337
|
+
if self.keep:
|
|
338
|
+
out.map_cube = map_cube
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
def _mapproject(self, cube: Path, map_cube: Path, tif: Path) -> None:
|
|
342
|
+
map_project(cube, map_cube, self.projection, self.resolution, keep=self.keep)
|
|
343
|
+
run("gdal_translate", [*GEOTIFF_OPTIONS, map_cube, tif])
|
|
344
|
+
if not self.keep and not is_dry_run():
|
|
345
|
+
map_cube.unlink(missing_ok=True)
|
|
346
|
+
|
|
347
|
+
def _spiceinit_params(self) -> dict[str, Any]:
|
|
348
|
+
params = dict(self.spiceinit_params)
|
|
349
|
+
if self.web:
|
|
350
|
+
params.pop("attach", None)
|
|
351
|
+
return params
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _remove_image(label: Path) -> None:
|
|
355
|
+
try:
|
|
356
|
+
name = image_pointer(label)
|
|
357
|
+
except OSError:
|
|
358
|
+
return
|
|
359
|
+
if name:
|
|
360
|
+
(label.parent / name).unlink(missing_ok=True)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def import_tc(label: Path, output_dir: Path | None = None, **kwargs: Any) -> TcOutputs:
|
|
364
|
+
"""Import one downloaded TC product; see :class:`TcProcessor` for *kwargs*."""
|
|
365
|
+
label = Path(label)
|
|
366
|
+
return TcProcessor(output_dir or label.parent, **kwargs).import_label(label)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _source_name(source: Any) -> str:
|
|
370
|
+
return source.product_id if isinstance(source, TcProduct) else str(source)
|