dcp-tools 1.0.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.
- dcp_tools/__init__.py +15 -0
- dcp_tools/__main__.py +6 -0
- dcp_tools/cli/__init__.py +5 -0
- dcp_tools/cli/common.py +9 -0
- dcp_tools/cli/csv2mcf.py +91 -0
- dcp_tools/cli/ingest.py +35 -0
- dcp_tools/cli/main.py +42 -0
- dcp_tools/cli/pipeline.py +45 -0
- dcp_tools/cli/upload.py +43 -0
- dcp_tools/custom_data/__init__.py +5 -0
- dcp_tools/custom_data/config_utils.py +188 -0
- dcp_tools/custom_data/data_management.py +1696 -0
- dcp_tools/custom_data/models/__init__.py +0 -0
- dcp_tools/custom_data/models/common.py +249 -0
- dcp_tools/custom_data/models/config_file.py +119 -0
- dcp_tools/custom_data/models/data_files.py +220 -0
- dcp_tools/custom_data/models/mcf.py +263 -0
- dcp_tools/custom_data/models/schema_nodes.py +137 -0
- dcp_tools/custom_data/models/sources.py +74 -0
- dcp_tools/custom_data/models/stat_vars.py +113 -0
- dcp_tools/custom_data/models/topics.py +33 -0
- dcp_tools/custom_data/models/vertical_specs.py +30 -0
- dcp_tools/custom_data/schema_tools.py +297 -0
- dcp_tools/gcp_utilities/__init__.py +22 -0
- dcp_tools/gcp_utilities/clients.py +23 -0
- dcp_tools/gcp_utilities/ingestion.py +36 -0
- dcp_tools/gcp_utilities/pipeline.py +45 -0
- dcp_tools/gcp_utilities/settings.py +106 -0
- dcp_tools/gcp_utilities/storage.py +412 -0
- dcp_tools/logger.py +25 -0
- dcp_tools/py.typed +0 -0
- dcp_tools-1.0.0.dist-info/METADATA +168 -0
- dcp_tools-1.0.0.dist-info/RECORD +36 -0
- dcp_tools-1.0.0.dist-info/WHEEL +4 -0
- dcp_tools-1.0.0.dist-info/entry_points.txt +3 -0
- dcp_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
dcp_tools/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from .custom_data.config_utils import merge_configs_from_directory
|
|
4
|
+
from .custom_data.data_management import CustomDataManager
|
|
5
|
+
from .custom_data.schema_tools import csv_metadata_to_mcf_file, csv_metadata_to_nodes
|
|
6
|
+
|
|
7
|
+
__version__ = version("dcp-tools")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CustomDataManager",
|
|
12
|
+
"csv_metadata_to_mcf_file",
|
|
13
|
+
"csv_metadata_to_nodes",
|
|
14
|
+
"merge_configs_from_directory",
|
|
15
|
+
]
|
dcp_tools/__main__.py
ADDED
dcp_tools/cli/common.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from dcp_tools.gcp_utilities import KGSettings, get_kg_settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_settings_from_args(args: argparse.Namespace) -> KGSettings:
|
|
7
|
+
if args.settings_file:
|
|
8
|
+
return get_kg_settings(source="json", file=args.settings_file)
|
|
9
|
+
return get_kg_settings(env_file=args.env_file)
|
dcp_tools/cli/csv2mcf.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CSV to MCF command implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dcp_tools.custom_data.schema_tools import (
|
|
9
|
+
NodeTypes,
|
|
10
|
+
csv_metadata_to_mcf_file,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["add_parser", "run"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _kv_pair(value: str) -> tuple[str, str]:
|
|
17
|
+
"""Parse a KEY=VALUE string into a tuple."""
|
|
18
|
+
if "=" not in value:
|
|
19
|
+
raise ValueError(f"Invalid key-value pair: {value}")
|
|
20
|
+
key, val = value.split("=", 1)
|
|
21
|
+
return key.strip(), val.strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
25
|
+
"""Register the ``csv2mcf`` subcommand with the CLI parser."""
|
|
26
|
+
parser = subparsers.add_parser(
|
|
27
|
+
"csv2mcf", help="Convert a CSV of Node metadata to an MCF file"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument("csv", type=Path, help="Path to the input CSV file")
|
|
31
|
+
parser.add_argument("mcf", type=Path, help="Path to write the generated MCF")
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--node-type",
|
|
35
|
+
choices=[node_type.value for node_type in NodeTypes],
|
|
36
|
+
default="Node",
|
|
37
|
+
help="Type of node to create (default: %(default)s)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--column-mapping",
|
|
42
|
+
metavar="CSV_COL=MCF_PROP",
|
|
43
|
+
type=_kv_pair,
|
|
44
|
+
action="append",
|
|
45
|
+
help=(
|
|
46
|
+
"Map CSV column names to MCF properties. "
|
|
47
|
+
"May be used multiple times, eg: "
|
|
48
|
+
"--column-mapping description=searchDescription --column-mapping indicator=Node"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--csv-option",
|
|
54
|
+
metavar="KEY=VALUE",
|
|
55
|
+
type=_kv_pair,
|
|
56
|
+
action="append",
|
|
57
|
+
help=(
|
|
58
|
+
"Extra keyword arguments forwarded to pandas.read_csv, "
|
|
59
|
+
'e.g. --csv-option delimiter=";" --csv-option encoding=UTF-8'
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--ignore-column",
|
|
65
|
+
metavar="COLUMN",
|
|
66
|
+
action="append",
|
|
67
|
+
help="Name of a CSV column to ignore. May be specified multiple times.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--append", action="store_true", help="Append to the output file if it exists."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
parser.set_defaults(func=run)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(args: argparse.Namespace) -> int:
|
|
78
|
+
"""Execute the ``csv2mcf`` command."""
|
|
79
|
+
column_mapping = dict(args.column_mapping) if args.column_mapping else None
|
|
80
|
+
csv_options = dict(args.csv_option) if args.csv_option else None
|
|
81
|
+
|
|
82
|
+
csv_metadata_to_mcf_file(
|
|
83
|
+
csv_path=args.csv,
|
|
84
|
+
mcf_path=args.mcf,
|
|
85
|
+
node_type=args.node_type,
|
|
86
|
+
column_to_property_mapping=column_mapping,
|
|
87
|
+
csv_options=csv_options,
|
|
88
|
+
ignore_columns=args.ignore_column,
|
|
89
|
+
overwrite=not args.append,
|
|
90
|
+
)
|
|
91
|
+
return 0
|
dcp_tools/cli/ingest.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Run the ingestion workflow via CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dcp_tools.cli.common import load_settings_from_args
|
|
7
|
+
from dcp_tools.gcp_utilities.ingestion import run_ingestion_workflow
|
|
8
|
+
|
|
9
|
+
__all__ = ["add_parser", "run"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
13
|
+
"""Register the ``ingest`` subcommand."""
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"ingest", help="Run the Knowledge Graph ingestion workflow"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--settings-file", type=Path, help="Path to the KG settings JSON file"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--env-file", type=Path, help="Optional .env file containing KG settings"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--imports",
|
|
25
|
+
type=str,
|
|
26
|
+
help="Comma-separated list of imports to ingest, defaults to all imports",
|
|
27
|
+
)
|
|
28
|
+
parser.set_defaults(func=run)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(args: argparse.Namespace) -> int:
|
|
32
|
+
"""Execute the ``ingest`` command."""
|
|
33
|
+
settings = load_settings_from_args(args)
|
|
34
|
+
run_ingestion_workflow(settings=settings, imports=args.imports)
|
|
35
|
+
return 0
|
dcp_tools/cli/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Entry point for the ``dcp_tools`` command line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from dcp_tools.cli import (
|
|
7
|
+
csv2mcf,
|
|
8
|
+
ingest,
|
|
9
|
+
pipeline,
|
|
10
|
+
upload,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["main"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
"""Create the CLI argument parser and register commands."""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
description="Utilities for working with Data Commons files"
|
|
20
|
+
)
|
|
21
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
csv2mcf.add_parser(subparsers)
|
|
23
|
+
upload.add_parser(subparsers)
|
|
24
|
+
ingest.add_parser(subparsers)
|
|
25
|
+
pipeline.add_parser(subparsers)
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
30
|
+
"""Parse ``argv`` and execute the selected command."""
|
|
31
|
+
parser = _build_parser()
|
|
32
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
33
|
+
|
|
34
|
+
if hasattr(args, "func"):
|
|
35
|
+
return args.func(args)
|
|
36
|
+
|
|
37
|
+
parser.error(f"Unknown command: {args.command}")
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Run the full Knowledge Graph pipeline via CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dcp_tools.cli.common import load_settings_from_args
|
|
9
|
+
from dcp_tools.gcp_utilities.ingestion import run_ingestion_workflow
|
|
10
|
+
from dcp_tools.gcp_utilities.pipeline import upload_to_cloud_storage
|
|
11
|
+
|
|
12
|
+
__all__ = ["add_parser", "run"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
16
|
+
"""Register the ``pipeline`` subcommand."""
|
|
17
|
+
parser = subparsers.add_parser(
|
|
18
|
+
"pipeline", help="Run the full Knowledge Graph loading pipeline"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--settings-file", type=Path, help="Path to the KG settings JSON file"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--env-file", type=Path, help="Optional .env file containing KG settings"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--directory",
|
|
28
|
+
type=Path,
|
|
29
|
+
help="Local directory to upload. Defaults to settings.LOCAL_PATH",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--sync",
|
|
33
|
+
action="store_true",
|
|
34
|
+
default=False,
|
|
35
|
+
help="Delete remote blobs that no longer have a local counterpart after uploading",
|
|
36
|
+
)
|
|
37
|
+
parser.set_defaults(func=run)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(args: argparse.Namespace) -> int:
|
|
41
|
+
"""Execute the ``pipeline`` command."""
|
|
42
|
+
settings = load_settings_from_args(args)
|
|
43
|
+
upload_to_cloud_storage(settings=settings, directory=args.directory, sync=args.sync)
|
|
44
|
+
run_ingestion_workflow(settings=settings)
|
|
45
|
+
return 0
|
dcp_tools/cli/upload.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Upload data to Cloud Storage via CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dcp_tools.cli.common import load_settings_from_args
|
|
9
|
+
from dcp_tools.gcp_utilities.pipeline import upload_to_cloud_storage
|
|
10
|
+
|
|
11
|
+
__all__ = ["add_parser", "run"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
15
|
+
"""Register the ``upload`` subcommand."""
|
|
16
|
+
parser = subparsers.add_parser(
|
|
17
|
+
"upload", help="Upload local data to Google Cloud Storage"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--settings-file", type=Path, help="Path to the KG settings JSON file"
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--env-file", type=Path, help="Optional .env file containing KG settings"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--directory",
|
|
27
|
+
type=Path,
|
|
28
|
+
help="Local directory to upload. Defaults to settings.LOCAL_PATH",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--sync",
|
|
32
|
+
action="store_true",
|
|
33
|
+
default=False,
|
|
34
|
+
help="Delete remote blobs that no longer have a local counterpart after uploading",
|
|
35
|
+
)
|
|
36
|
+
parser.set_defaults(func=run)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(args: argparse.Namespace) -> int:
|
|
40
|
+
"""Execute the ``upload`` command."""
|
|
41
|
+
settings = load_settings_from_args(args)
|
|
42
|
+
upload_to_cloud_storage(settings=settings, directory=args.directory, sync=args.sync)
|
|
43
|
+
return 0
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Utilities for merging config files."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from dcp_tools.custom_data.models.config_file import Config
|
|
9
|
+
from dcp_tools.logger import logger
|
|
10
|
+
|
|
11
|
+
DuplicatePolicy = Literal["error", "override", "ignore"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def iter_config_files(directory: Path, pattern: str = "config.json") -> Iterator[Path]:
|
|
15
|
+
"""Yield all ``config.json`` files under ``directory`` recursively.
|
|
16
|
+
|
|
17
|
+
Files are yielded in sorted order so that merge results are deterministic
|
|
18
|
+
across platforms.
|
|
19
|
+
"""
|
|
20
|
+
for path in sorted(directory.rglob(pattern)):
|
|
21
|
+
if path.is_file():
|
|
22
|
+
yield path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _merge_simple_attrs(existing: Config, new: Config, policy: DuplicatePolicy) -> None:
|
|
26
|
+
"""Merge simple attributes that are booleans, strings, or None."""
|
|
27
|
+
for attr in (
|
|
28
|
+
"import_name",
|
|
29
|
+
"include_input_subdirs",
|
|
30
|
+
"group_stat_vars_by_property",
|
|
31
|
+
"default_custom_root_stat_var_group_name",
|
|
32
|
+
"custom_id_namespace",
|
|
33
|
+
"custom_svg_prefix",
|
|
34
|
+
"vertical_specs_file",
|
|
35
|
+
):
|
|
36
|
+
_merge_attribute(existing, new, attr, policy)
|
|
37
|
+
|
|
38
|
+
_merge_sequence_attribute(
|
|
39
|
+
existing=existing,
|
|
40
|
+
new=new,
|
|
41
|
+
attribute="sv_hierarchy_props_blocklist",
|
|
42
|
+
policy=policy,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_merge_sequence_attribute(
|
|
46
|
+
existing=existing,
|
|
47
|
+
new=new,
|
|
48
|
+
attribute="data_download_url",
|
|
49
|
+
policy=policy,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _handle_conflict(
|
|
54
|
+
field: str, target_value: Any, source_value: Any, policy: DuplicatePolicy
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Log or raise depending on *policy* when a conflict is detected."""
|
|
57
|
+
if policy == "override":
|
|
58
|
+
logger.warning(f"Overriding {field}: {target_value} -> {source_value}")
|
|
59
|
+
elif policy == "ignore":
|
|
60
|
+
logger.info(f"Ignoring {field}: {target_value} -> {source_value}")
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"Conflicting {field}: {target_value!r} vs {source_value!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _merge_input_files(existing: Config, new: Config, policy: DuplicatePolicy) -> None:
|
|
66
|
+
"""Merge input file entries from *new* into *existing* in-place.
|
|
67
|
+
|
|
68
|
+
Entries are keyed by ``filename`` or ``pattern`` (whichever is set).
|
|
69
|
+
Conflict semantics follow *policy*: ``"error"`` raises, ``"override"``
|
|
70
|
+
replaces, ``"ignore"`` and equal entries are skipped.
|
|
71
|
+
"""
|
|
72
|
+
existing_by_key: dict[str, int] = {}
|
|
73
|
+
for i, e in enumerate(existing.input_files):
|
|
74
|
+
k = e.filename or e.pattern
|
|
75
|
+
if k is not None:
|
|
76
|
+
existing_by_key[k] = i
|
|
77
|
+
|
|
78
|
+
for entry in new.input_files:
|
|
79
|
+
key = entry.filename or entry.pattern
|
|
80
|
+
if key is None:
|
|
81
|
+
# Should not happen: InputFile enforces filename XOR pattern.
|
|
82
|
+
continue
|
|
83
|
+
if key not in existing_by_key:
|
|
84
|
+
logger.info(f"Added input file '{key}'")
|
|
85
|
+
existing.input_files.append(entry)
|
|
86
|
+
existing_by_key[key] = len(existing.input_files) - 1
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
idx = existing_by_key[key]
|
|
90
|
+
tgt = existing.input_files[idx]
|
|
91
|
+
if tgt == entry:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
_handle_conflict(
|
|
95
|
+
field=f"Input file '{key}'",
|
|
96
|
+
target_value=tgt,
|
|
97
|
+
source_value=entry,
|
|
98
|
+
policy=policy,
|
|
99
|
+
)
|
|
100
|
+
if policy == "override":
|
|
101
|
+
existing.input_files[idx] = entry
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _merge_attribute(
|
|
105
|
+
existing: Config, new: Config, attribute: str, policy: DuplicatePolicy
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Merge a single attribute, handling collisions with *policy*."""
|
|
108
|
+
src_val = getattr(new, attribute)
|
|
109
|
+
if src_val is None:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
tgt_val = getattr(existing, attribute)
|
|
113
|
+
if tgt_val is None or tgt_val == src_val:
|
|
114
|
+
setattr(existing, attribute, src_val if tgt_val is None else tgt_val)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
_handle_conflict(
|
|
118
|
+
field=attribute, target_value=tgt_val, source_value=src_val, policy=policy
|
|
119
|
+
)
|
|
120
|
+
if policy == "override":
|
|
121
|
+
setattr(existing, attribute, src_val)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _merge_sequence_attribute(
|
|
125
|
+
existing: Config, new: Config, attribute: str, policy: DuplicatePolicy
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Merge a sequence attribute, normalising duplicates when overriding."""
|
|
128
|
+
src_val = getattr(new, attribute)
|
|
129
|
+
if not src_val:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
tgt_val = getattr(existing, attribute)
|
|
133
|
+
if not tgt_val:
|
|
134
|
+
setattr(existing, attribute, list(dict.fromkeys(src_val)))
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if tgt_val == src_val:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
_handle_conflict(
|
|
141
|
+
field=attribute, target_value=tgt_val, source_value=src_val, policy=policy
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if policy == "override":
|
|
145
|
+
# Preserve the order of the incoming values but drop duplicates
|
|
146
|
+
setattr(existing, attribute, list(dict.fromkeys(src_val)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def merge_configs(
|
|
150
|
+
existing: Config, new: Config, *, policy: DuplicatePolicy = "error"
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Merge ``new`` into ``existing`` in-place.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
existing: target config.
|
|
156
|
+
new: the config to be merged with *existing*.
|
|
157
|
+
policy: How to resolve collisions.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If policy is `"error" and conflicting
|
|
161
|
+
values are encountered.
|
|
162
|
+
"""
|
|
163
|
+
# Merge attributes that are booleans or None
|
|
164
|
+
_merge_simple_attrs(existing=existing, new=new, policy=policy)
|
|
165
|
+
|
|
166
|
+
# Merge the input file list
|
|
167
|
+
_merge_input_files(existing=existing, new=new, policy=policy)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def merge_configs_from_directory(
|
|
171
|
+
directory: str | PathLike[str], *, policy: DuplicatePolicy = "error"
|
|
172
|
+
) -> Config:
|
|
173
|
+
"""Return a ``Config`` merging all configs found under ``directory``.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
directory: Directory to search for config files.
|
|
177
|
+
policy: How to resolve collisions.
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If policy is `"error"` and conflicting
|
|
180
|
+
values are encountered.
|
|
181
|
+
|
|
182
|
+
"""
|
|
183
|
+
base = Config(input_files=[])
|
|
184
|
+
for path in iter_config_files(Path(directory)):
|
|
185
|
+
logger.info(f"Merging config file {path}")
|
|
186
|
+
config = Config.from_json(str(path))
|
|
187
|
+
merge_configs(existing=base, new=config, policy=policy)
|
|
188
|
+
return base
|