Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cdm_data_loaders/core/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ class LoaderFileFormatEnum(StrEnum):
}
)


NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]
CharStr = Annotated[str, StringConstraints(min_length=1, max_length=1)]

BatchSize = Annotated[
PositiveInt,
Expand Down
37 changes: 32 additions & 5 deletions src/cdm_data_loaders/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Settings classes for data loading and wrangling pipelines."""

from pathlib import Path
from typing import TYPE_CHECKING, Any, Final
from typing import TYPE_CHECKING, Annotated, Any, Final

from frozendict import frozendict
from pydantic import AliasChoices, AliasPath, computed_field
from pydantic import AliasChoices, AliasPath, Field, computed_field
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, SettingsConfigDict, SettingsError

from cdm_data_loaders.core.fields import (
DEFAULT_XML_FILE_GLOB,
FILE_GLOB,
INPUT_DIR,
LOADER_FILE_FORMAT,
OUTPUT_DIR,
Expand All @@ -17,6 +19,7 @@
BufferSize,
DatasetName,
DltDevMode,
FileGlob,
LoaderFileFormat,
LogConfigFile,
LogInterval,
Expand Down Expand Up @@ -47,6 +50,18 @@
USE_OUTPUT_DIR_FOR_PIPELINE_METADATA: "p",
}
)
FILE_FORMAT_CLI_SHORTCUTS: frozendict[str, str] = frozendict(
{
**CLI_SHORTCUTS,
LOADER_FILE_FORMAT: "f",
}
)
XML_CLI_SHORTCUTS: frozendict[str, str] = frozendict(
{
**FILE_FORMAT_CLI_SHORTCUTS,
FILE_GLOB: "g",
}
)

# argparse registers -h/--help on every parser; claiming either makes the parser fail to build
RESERVED_CLI_NAMES: Final[frozenset[str]] = frozenset({"h", "help"})
Expand Down Expand Up @@ -91,7 +106,7 @@
model_config = SettingsConfigDict(**DEFAULT_SETTINGS_CONFIG_DICT)

@classmethod
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:

Check failure on line 109 in src/cdm_data_loaders/core/settings.py

View workflow job for this annotation

GitHub Actions / Check code formatting

ruff (ANN401)

src/cdm_data_loaders/core/settings.py:109:51: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `**kwargs`
"""Validate the subclass's cli shortcuts and aliases."""
super().__pydantic_init_subclass__(**kwargs)
cls.check_aliases()
Expand Down Expand Up @@ -210,9 +225,21 @@
class FileFormatIngestSettings(CtsSettings):
"""Configuration for DLT pipelines that import a particular file format."""

model_config: SettingsConfigDict = default_settings_with_shortcuts(
cli_shortcuts={**CLI_SHORTCUTS, LOADER_FILE_FORMAT: "f"}
)
model_config: SettingsConfigDict = default_settings_with_shortcuts(cli_shortcuts=FILE_FORMAT_CLI_SHORTCUTS)

dataset_name: DatasetName
loader_file_format: LoaderFileFormat


class XmlIngestSettings(FileFormatIngestSettings):
"""Configuration for DLT pipelines that import XML files."""

model_config: SettingsConfigDict = default_settings_with_shortcuts(cli_shortcuts=XML_CLI_SHORTCUTS)

file_glob: Annotated[
FileGlob,
Field(
default=DEFAULT_XML_FILE_GLOB,
description="Glob pattern for XML files",
),
]
8 changes: 3 additions & 5 deletions src/cdm_data_loaders/pipelines/all_the_bacteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@
from functools import cached_property
from logging import Logger, getLogger
from pathlib import Path
from typing import Annotated, Any, Final, Self
from typing import Any, Final, Self

Check warning on line 19 in src/cdm_data_loaders/pipelines/all_the_bacteria.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/all_the_bacteria.py#L19

Added line #L19 was not covered by tests

import dlt
import dlt.sources.helpers.requests
from dlt.common.pipeline import LoadInfo
from dlt.extract.items import DataItemWithMeta
from dlt.sources.helpers.rest_client.client import RESTClient
from pydantic import Field, StringConstraints, computed_field, model_validator
from pydantic import Field, computed_field, model_validator

Check warning on line 25 in src/cdm_data_loaders/pipelines/all_the_bacteria.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/all_the_bacteria.py#L25

Added line #L25 was not covered by tests
from pydantic_settings import SettingsConfigDict

from cdm_data_loaders.core.fields import NonEmptyStr

Check warning on line 28 in src/cdm_data_loaders/pipelines/all_the_bacteria.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/all_the_bacteria.py#L28

Added line #L28 was not covered by tests
from cdm_data_loaders.core.settings import (
CLI_SHORTCUTS,
CtsSettings,
Expand All @@ -39,8 +39,6 @@

logger: Logger = getLogger(__name__)

NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]


DATASET_NAME = "all_the_bacteria"
ALL_FILES_TSV_FILE_ID = "R6gcp"
Expand Down
89 changes: 86 additions & 3 deletions src/cdm_data_loaders/pipelines/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@
import dlt
from dlt.common.pipeline import LoadInfo
from dlt.common.runtime.slack import send_slack_message
from dlt.common.schema.typing import TSchemaContractDict
from dlt.extract import DltResource
from dlt.pipeline.exceptions import PipelineStepFailed
from dlt.sources.filesystem import filesystem
from pydantic import ValidationError
from pydantic_settings import CliApp, SettingsError

from cdm_data_loaders.core.fields import JSONL
from cdm_data_loaders.core.settings import CtsSettings, LoggerSettings
from cdm_data_loaders.core.fields import DEFAULT_XML_FILE_GLOB, JSONL
from cdm_data_loaders.core.settings import (
CtsSettings,
FileFormatIngestSettings,
LocalInputOutputSettings,
LoggerSettings,
)
from cdm_data_loaders.utils.cdm_logger import init_logger

WEBHOOK_NOT_CONFIGURED: Final[str] = "Slack webhook not configured"
Expand All @@ -27,6 +34,12 @@
DESTINATION_NOT_CONFIGURED: Final[str] = (
"Destination {name!r} is not configured: set destination.{name}.destination_type in the dlt config."
)
DEFAULT_SCHEMA_CONTRACT: Final[TSchemaContractDict] = {
"tables": "evolve",
"columns": "evolve",
"data_type": "discard_row",
}
DEFAULT_REJECTED_SUFFIX: Final[str] = "_rejected"

logger: Logger = getLogger(__name__)

Expand Down Expand Up @@ -184,7 +197,19 @@
try:
# save the load info as JSONL in the same destination as the ingested data
load_info_resource: DltResource = dlt.resource(
[load_info.asdict()], name=LOAD_INFO_TABLE_NAME, max_table_nesting=0
[load_info.asdict()],
name=LOAD_INFO_TABLE_NAME,
max_table_nesting=0,
columns={
k: {"data_type": "text"}
for k in [
"environment",
"staging_type",
"staging_name",
"staging_display_credentials",
"dataset_name",
]
},
)
load_info_save_info: LoadInfo = pipeline.run(load_info_resource, loader_file_format=str(JSONL))
if load_info_save_info.has_failed_jobs:
Expand Down Expand Up @@ -300,3 +325,61 @@
send_slack_message_carefully(slack_hook, msg)

return load_info


def build_filesystem_resource(
settings: LocalInputOutputSettings,
reader_fn: Callable[..., Any] | DltResource,
*reader_args: Any, # noqa: ANN401
name: str,
file_glob: str | None = None,
schema_contract: TSchemaContractDict | None = None,
**transformer_kwargs: Any, # noqa: ANN401
) -> DltResource:
"""Build a dlt resource that pipes ``filesystem(settings.input_dir, file_glob)`` into a parallelized transformer.

:param settings: pipeline settings with ``input_dir`` (and optionally ``file_glob``)
:param reader_fn: transformer function or resource accepting ``items`` followed by ``*reader_args``
:param reader_args: positional arguments bound to the transformer after ``items``
:param name: name of the resulting dlt transformer resource
:param file_glob: glob pattern for files; defaults to ``settings.file_glob`` if present, else ``DEFAULT_XML_FILE_GLOB``
:param schema_contract: optional dlt schema contract hint applied to the resource
:param transformer_kwargs: additional keyword arguments passed to ``dlt.transformer``
:return: configured ``DltResource``
"""
resolved_glob = file_glob if file_glob is not None else getattr(settings, "file_glob", DEFAULT_XML_FILE_GLOB)
files = filesystem(bucket_url=str(settings.input_dir), file_glob=resolved_glob)
reader = (

Check warning on line 352 in src/cdm_data_loaders/pipelines/core.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/core.py#L350-L352

Added lines #L350 - L352 were not covered by tests
reader_fn
if isinstance(reader_fn, DltResource)
else dlt.transformer(reader_fn, name=name, parallelized=True, **transformer_kwargs)
)
resource: DltResource = files | reader(*reader_args)
if schema_contract is not None:
resource.apply_hints(schema_contract=TSchemaContractDict(**schema_contract)) # pyright: ignore[reportCallIssue]
return resource

Check warning on line 360 in src/cdm_data_loaders/pipelines/core.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/core.py#L357-L360

Added lines #L357 - L360 were not covered by tests


def build_file_ingest_kwargs(
settings: FileFormatIngestSettings,
pipeline_name: str,
*,
fallback_dataset_name: bool = False,
) -> dict[str, dict[str, Any]]:
"""Build the standard ``pipeline_kwargs`` and ``pipeline_run_kwargs`` for a file-ingest pipeline.

:param settings: file-format ingest settings with ``dataset_name`` and ``loader_file_format``
:param pipeline_name: name of the dlt pipeline
:param fallback_dataset_name: whether to fall back to ``pipeline_name`` when ``settings.dataset_name`` is None
:return: mapping with ``pipeline_kwargs`` and ``pipeline_run_kwargs`` suitable for ``run_pipeline``
"""
dataset_name = (settings.dataset_name or pipeline_name) if fallback_dataset_name else settings.dataset_name
return {

Check warning on line 377 in src/cdm_data_loaders/pipelines/core.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/core.py#L376-L377

Added lines #L376 - L377 were not covered by tests
"pipeline_kwargs": {
"pipeline_name": pipeline_name,
"dataset_name": dataset_name,
},
"pipeline_run_kwargs": {
"loader_file_format": str(settings.loader_file_format),
},
}
36 changes: 28 additions & 8 deletions src/cdm_data_loaders/pipelines/jsonlines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

from collections.abc import Callable, Generator
from importlib import import_module

Check warning on line 11 in src/cdm_data_loaders/pipelines/jsonlines/base.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/base.py#L11

Added line #L11 was not covered by tests
from logging import Logger, getLogger
from typing import TYPE_CHECKING, Any, Final

Expand All @@ -16,7 +17,11 @@
from dlt.common.schema.typing import TSchemaContractDict
from dlt.sources.filesystem import filesystem

from cdm_data_loaders.pipelines.core import run_pipeline
from cdm_data_loaders.pipelines.core import (

Check warning on line 20 in src/cdm_data_loaders/pipelines/jsonlines/base.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/base.py#L20

Added line #L20 was not covered by tests
DEFAULT_REJECTED_SUFFIX,
DEFAULT_SCHEMA_CONTRACT,
run_pipeline,
)
from cdm_data_loaders.readers.jsonlines import stream_jsonl_lines
from cdm_data_loaders.utils.buffer import ListBuffer

Expand All @@ -25,20 +30,35 @@

logger: Logger = getLogger(__name__)

SCHEMA_CONTRACT: Final[TSchemaContractDict] = {
"tables": "evolve",
"columns": "evolve",
"data_type": "discard_row",
}

DEFAULT_REJECTED_SUFFIX: Final[str] = "_rejected"
SCHEMA_CONTRACT: Final[TSchemaContractDict] = DEFAULT_SCHEMA_CONTRACT

Check warning on line 33 in src/cdm_data_loaders/pipelines/jsonlines/base.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/base.py#L33

Added line #L33 was not covered by tests

REJECTED_COLUMNS: Final[tuple[str, ...]] = ("source_file", "line_no", "raw_record")

ParseErrorResolver = Callable[[dict[str, Any]], str | None]
ResourceFactory = Callable[[str, dict[str, Any], Any], "DltResource"]


def load_registry_dict(module_path: str, attribute_name: str, type_hint: str = "") -> dict[str, Any]:

Check warning on line 41 in src/cdm_data_loaders/pipelines/jsonlines/base.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/base.py#L41

Added line #L41 was not covered by tests
"""Import ``module_path`` and return its non-empty dictionary attribute ``attribute_name``.

:param module_path: dotted import path of the registry module
:type module_path: str
:param attribute_name: name of the dictionary attribute on the module
:type attribute_name: str
:param type_hint: optional suffix included in the ValueError message when missing or empty
:type type_hint: str
:return: the registry dictionary
:rtype: dict[str, Any]
:raises ValueError: if the attribute is missing, empty, or not a dict
"""
module = import_module(module_path)
registry = getattr(module, attribute_name, None)
if not isinstance(registry, dict) or not registry:
err_msg = f"{module_path} does not define a non-empty {attribute_name}{type_hint}"
raise ValueError(err_msg)
return registry

Check warning on line 59 in src/cdm_data_loaders/pipelines/jsonlines/base.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/base.py#L54-L59

Added lines #L54 - L59 were not covered by tests


def make_page_router( # noqa: PLR0913
table_name: str,
buffer_size: int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"""

import json
from importlib import import_module
from logging import Logger, getLogger
from typing import Any

Expand All @@ -21,6 +20,7 @@
from cdm_data_loaders.pipelines.core import PipelineCli
from cdm_data_loaders.pipelines.jsonlines.base import (
ParseErrorResolver,
load_registry_dict,
run_registry_ingest,
)
from cdm_data_loaders.pipelines.jsonlines.base import (
Expand All @@ -44,11 +44,7 @@
:rtype: dict[str, dict[str, Any]]
:raises ValueError: if ENTITY_SCHEMAS is missing, empty, or not a dict
"""
module = import_module(module_path)
registry = getattr(module, "ENTITY_SCHEMAS", None)
if not isinstance(registry, dict) or not registry:
err_msg = f"{module_path} does not define a non-empty ENTITY_SCHEMAS"
raise ValueError(err_msg)
registry = load_registry_dict(module_path, "ENTITY_SCHEMAS")

Check warning on line 47 in src/cdm_data_loaders/pipelines/jsonlines/extract_jsonschema_validate_pipeline.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/extract_jsonschema_validate_pipeline.py#L47

Added line #L47 was not covered by tests
errors: dict[str, str] = {}
for table_name, schema in registry.items():
if not isinstance(table_name, str) or not table_name.strip():
Expand Down
20 changes: 8 additions & 12 deletions src/cdm_data_loaders/pipelines/jsonlines/extract_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
from dlt.common.pipeline import LoadInfo
from dlt.common.storages.fsspec_filesystem import FileItemDict
from dlt.common.typing import TDataItems
from dlt.sources.filesystem import filesystem

from cdm_data_loaders.pipelines.core import PipelineCli, run_pipeline
from cdm_data_loaders.pipelines.core import (

Check warning on line 19 in src/cdm_data_loaders/pipelines/jsonlines/extract_pipeline.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/extract_pipeline.py#L19

Added line #L19 was not covered by tests
PipelineCli,
build_file_ingest_kwargs,
build_filesystem_resource,
run_pipeline,
)
from cdm_data_loaders.pipelines.jsonlines.settings import (
EXTRACT_PIPELINE_NAME,
JsonlExtractSettings,
Expand Down Expand Up @@ -47,20 +51,12 @@
:return: load information for the pipeline
:rtype: LoadInfo | None
"""
files = filesystem(bucket_url=str(settings.input_dir), file_glob=settings.file_glob)

jsonl_resource = files | jsonl_reader(settings)
jsonl_resource = build_filesystem_resource(settings, jsonl_reader, settings, name="jsonl_reader")

Check warning on line 54 in src/cdm_data_loaders/pipelines/jsonlines/extract_pipeline.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/extract_pipeline.py#L54

Added line #L54 was not covered by tests

return run_pipeline(
settings=settings,
resource=jsonl_resource,
pipeline_kwargs={
"pipeline_name": EXTRACT_PIPELINE_NAME,
"dataset_name": settings.dataset_name or EXTRACT_PIPELINE_NAME,
},
pipeline_run_kwargs={
"loader_file_format": str(settings.loader_file_format),
},
**build_file_ingest_kwargs(settings, EXTRACT_PIPELINE_NAME, fallback_dataset_name=True),
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"""

import json
from importlib import import_module
from typing import Any

from dlt.common.pipeline import LoadInfo
Expand All @@ -40,6 +39,7 @@
from cdm_data_loaders.pipelines.core import PipelineCli
from cdm_data_loaders.pipelines.jsonlines.base import (
ParseErrorResolver,
load_registry_dict,
run_registry_ingest,
)
from cdm_data_loaders.pipelines.jsonlines.base import (
Expand All @@ -60,12 +60,7 @@
:rtype: dict[str, type[BaseModel]]
:raises ValueError: if ENTITY_MODELS is missing, empty, or not a dict
"""
module = import_module(module_path)
registry = getattr(module, "ENTITY_MODELS", None)
if not isinstance(registry, dict) or not registry:
err_msg = f"{module_path} does not define a non-empty ENTITY_MODELS: dict[str, type[BaseModel]]"
raise ValueError(err_msg)
return registry
return load_registry_dict(module_path, "ENTITY_MODELS", type_hint=": dict[str, type[BaseModel]]")

Check warning on line 63 in src/cdm_data_loaders/pipelines/jsonlines/extract_pydantic_validate_pipeline.py

View check run for this annotation

Codecov / codecov/patch

src/cdm_data_loaders/pipelines/jsonlines/extract_pydantic_validate_pipeline.py#L63

Added line #L63 was not covered by tests


def _make_resolve_error(model: type[BaseModel]) -> ParseErrorResolver:
Expand Down
Loading
Loading