#
# Copyright © 2018-2026 Commonwealth Scientific and Industrial Research
# Organisation (CSIRO) ABN 41 687 119 230.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""The Pathling terminology import commands.
Each command creates a session, imports terminology content into a local store
through the library API, and reports progress and a completion summary. Import
runs as Spark jobs; per-stage progress is logged by the library and, in verbose
mode, streamed to stderr.
Author: John Grimes.
"""
from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING, Optional
import click
from rich.console import Console
from pathling.cli import session
from pathling.cli.config import CliConfig
from pathling.cli.errors import EXIT_USAGE, CliError, unwrap_java_exception
from pathling.cli.render import progress_status
if TYPE_CHECKING:
from pyspark.sql import Row, SparkSession
from pathling import PathlingContext
from pathling.cli.main import CliContext
# The name of the manifest table within a terminology store.
MANIFEST_TABLE = "manifest"
# The fixed phrase the library uses when a package does not match the checksum
# its registry publishes. The library must not name a CLI flag, so the hint
# naming --no-verify is added here, keyed off this phrase.
MISMATCH_PHRASE = "does not match the registry checksum"
# The hint appended to a checksum mismatch failure.
MISMATCH_HINT = "Re-run with --no-verify to import it anyway."
def _import_context(config: CliConfig, console: Console) -> PathlingContext:
"""Creates a context for importing into a store.
The import commands populate a store rather than query terminology, so the
context must not enter local mode: doing so would eagerly validate the
target store and reject a not-yet-created one, breaking the first import
into a configured store (FR-010). Clearing ``tx_store`` restores the plain
context used before local mode existed; the resolved import target is passed
to the import methods directly.
:param config: the resolved CLI configuration.
:param console: the stderr console for the status spinner.
:return: a configured :class:`PathlingContext` not bound to any store.
"""
return session.create_context(dataclasses.replace(config, tx_store=None), console)
def _resolve_storage_path(config: CliConfig, storage_path: Optional[str]) -> str:
"""Resolves the target store path, falling back to the configured store.
The explicit ``STORAGE_PATH`` positional wins; otherwise the configured
``tx-store.path`` is used. When neither is available the command is a usage
error naming both mechanisms (FR-010).
:param config: the resolved CLI configuration.
:param storage_path: the ``STORAGE_PATH`` positional, or None when omitted.
:return: the resolved store path.
:raises CliError: with EXIT_USAGE when no path is available from either
source.
"""
if storage_path is not None:
return storage_path
if config.tx_store is not None:
return config.tx_store.path
raise CliError(
"No storage path given. Provide the STORAGE_PATH argument, or configure "
"'tx-store.path' (or the --tx-store flag).",
exit_code=EXIT_USAGE,
)
[docs]def read_latest_import_row(
spark: SparkSession, store: str, source: str
) -> Optional[Row]:
"""Reads the most recent manifest row recorded for a source.
The store manifest carries the provenance of each import: the source hash,
the package identity, and the outcome of the registry check. The latest row
for this source describes the import that has just completed.
A failure to read the manifest yields None rather than an error: the import
has already committed, and reporting less detail is better than failing a
command whose work succeeded.
:param spark: the Spark session to read with.
:param store: the terminology store path.
:param source: the import source, matched against the manifest ``source``
column.
:return: the latest manifest row for the source, or None when there is none
or the manifest cannot be read.
"""
from pyspark.sql import functions as F
manifest_path = f"{store.rstrip('/')}/{MANIFEST_TABLE}"
try:
rows = (
spark.read.format("delta")
.load(manifest_path)
.filter(F.col("source") == source)
.orderBy(F.col("imported_at").desc())
.take(1)
)
except Exception: # noqa: BLE001 - detail only; the import already committed.
return None
return rows[0] if rows else None
@click.command(name="import-snomed")
@click.argument("source")
@click.argument("storage_path", required=False)
@click.option(
"--edition-uri", "edition_uri", help="Override the SNOMED edition/version URI."
)
@click.option(
"--dense-id-order",
"dense_id_order",
type=click.Choice(["code-order", "pre-order"]),
default="code-order",
show_default=True,
help=(
"How internal concept identifiers are assigned. 'pre-order' makes the "
"hierarchy index materially smaller, in exchange for identifiers that "
"shift more between releases."
),
)
@click.option(
"--default-dialect",
"default_dialect",
help=(
"The dialect whose preferred synonyms become the stored display: a tag "
"such as 'en-GB', or a language reference set identifier. Falls back to "
"the 'tx-store.default-dialect' config key; chosen from the release "
"when neither is set."
),
)
@click.pass_obj
def import_snomed(
obj: CliContext,
source: str,
storage_path: Optional[str],
edition_uri: Optional[str],
dense_id_order: str,
default_dialect: Optional[str],
) -> None:
"""Import a SNOMED CT RF2 snapshot release into a local terminology store.
STORAGE_PATH may be omitted when 'tx-store.path' (or --tx-store) is set.
Example:
pathling import-snomed /data/rf2.zip /data/tx-store
"""
config = obj.config
console = obj.console
resolved_path = _resolve_storage_path(config, storage_path)
resolved_dialect = (
default_dialect
if default_dialect is not None
else (config.tx_store.default_dialect if config.tx_store is not None else None)
)
pc = _import_context(config, console)
with progress_status(console, "Importing SNOMED CT...", config.verbose):
pc.import_snomed(
source, resolved_path, edition_uri, dense_id_order, resolved_dialect
)
row = read_latest_import_row(pc.spark, resolved_path, source)
click.echo(format_import_summary("SNOMED CT", source, resolved_path, row))
@click.command(name="import-fhir-terminology")
@click.argument("source")
@click.argument("storage_path", required=False)
@click.option(
"--no-verify",
"no_verify",
is_flag=True,
help=(
"Do not check a FHIR NPM package against its registry's published "
"checksum. Use for offline imports or packages not published to a "
"registry."
),
)
@click.option(
"--package-registry",
"package_registry",
metavar="URL",
help=(
"The FHIR package registry to check a package against. Falls back to "
"the 'package-registry' config key, then https://packages.fhir.org."
),
)
@click.pass_obj
def import_fhir_terminology(
obj: CliContext,
source: str,
storage_path: Optional[str],
no_verify: bool,
package_registry: Optional[str],
) -> None:
"""Import FHIR CodeSystem, ValueSet, and ConceptMap resources into a store.
The source may be a JSON file, a directory of JSON files, or a FHIR NPM
package (.tgz). A package is checked against the checksum its registry
publishes; a package that does not match fails the import and leaves the
store unchanged. STORAGE_PATH may be omitted when 'tx-store.path' (or
--tx-store) is set.
Example:
pathling import-fhir-terminology /data/hl7.terminology.tgz /data/tx-store
"""
config = obj.config
console = obj.console
resolved_path = _resolve_storage_path(config, storage_path)
# The flag wins over the config key; with neither set no registry is named
# and the library default applies, so the CLI never holds that URL.
resolved_registry = (
package_registry if package_registry is not None else config.package_registry
)
pc = _import_context(config, console)
with progress_status(console, "Importing FHIR terminology...", config.verbose):
try:
pc.import_fhir_terminology(
source,
resolved_path,
verify_package=not no_verify,
package_registry=resolved_registry,
)
except Exception as exc: # noqa: BLE001 - enrich a mismatch failure.
root_message = unwrap_java_exception(exc)
if MISMATCH_PHRASE not in root_message:
raise
raise CliError(f"{root_message} {MISMATCH_HINT}") from exc
row = read_latest_import_row(pc.spark, resolved_path, source)
click.echo(format_import_summary("FHIR terminology", source, resolved_path, row))
#: The terminology import commands registered by the root command group.
IMPORT_COMMANDS = (import_snomed, import_fhir_terminology)