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.
Files changed (36) hide show
  1. dcp_tools/__init__.py +15 -0
  2. dcp_tools/__main__.py +6 -0
  3. dcp_tools/cli/__init__.py +5 -0
  4. dcp_tools/cli/common.py +9 -0
  5. dcp_tools/cli/csv2mcf.py +91 -0
  6. dcp_tools/cli/ingest.py +35 -0
  7. dcp_tools/cli/main.py +42 -0
  8. dcp_tools/cli/pipeline.py +45 -0
  9. dcp_tools/cli/upload.py +43 -0
  10. dcp_tools/custom_data/__init__.py +5 -0
  11. dcp_tools/custom_data/config_utils.py +188 -0
  12. dcp_tools/custom_data/data_management.py +1696 -0
  13. dcp_tools/custom_data/models/__init__.py +0 -0
  14. dcp_tools/custom_data/models/common.py +249 -0
  15. dcp_tools/custom_data/models/config_file.py +119 -0
  16. dcp_tools/custom_data/models/data_files.py +220 -0
  17. dcp_tools/custom_data/models/mcf.py +263 -0
  18. dcp_tools/custom_data/models/schema_nodes.py +137 -0
  19. dcp_tools/custom_data/models/sources.py +74 -0
  20. dcp_tools/custom_data/models/stat_vars.py +113 -0
  21. dcp_tools/custom_data/models/topics.py +33 -0
  22. dcp_tools/custom_data/models/vertical_specs.py +30 -0
  23. dcp_tools/custom_data/schema_tools.py +297 -0
  24. dcp_tools/gcp_utilities/__init__.py +22 -0
  25. dcp_tools/gcp_utilities/clients.py +23 -0
  26. dcp_tools/gcp_utilities/ingestion.py +36 -0
  27. dcp_tools/gcp_utilities/pipeline.py +45 -0
  28. dcp_tools/gcp_utilities/settings.py +106 -0
  29. dcp_tools/gcp_utilities/storage.py +412 -0
  30. dcp_tools/logger.py +25 -0
  31. dcp_tools/py.typed +0 -0
  32. dcp_tools-1.0.0.dist-info/METADATA +168 -0
  33. dcp_tools-1.0.0.dist-info/RECORD +36 -0
  34. dcp_tools-1.0.0.dist-info/WHEEL +4 -0
  35. dcp_tools-1.0.0.dist-info/entry_points.txt +3 -0
  36. 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
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m dcp_tools``."""
2
+
3
+ from dcp_tools.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Command line interface for ``dcp_tools`` package."""
2
+
3
+ from dcp_tools.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -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)
@@ -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
@@ -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
@@ -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,5 @@
1
+ from dcp_tools.custom_data.data_management import CustomDataManager
2
+
3
+ __all__ = [
4
+ "CustomDataManager",
5
+ ]
@@ -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