focus-data-toolkit 0.11.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.
- focus_data_toolkit/__init__.py +69 -0
- focus_data_toolkit/__main__.py +6 -0
- focus_data_toolkit/_version.py +8 -0
- focus_data_toolkit/cli.py +968 -0
- focus_data_toolkit/context/__init__.py +88 -0
- focus_data_toolkit/context/billing.py +54 -0
- focus_data_toolkit/context/provider.py +90 -0
- focus_data_toolkit/convert/__init__.py +708 -0
- focus_data_toolkit/convert/billing_period.py +65 -0
- focus_data_toolkit/convert/contract_applied.py +235 -0
- focus_data_toolkit/convert/contract_commitment.py +182 -0
- focus_data_toolkit/convert/cost_and_usage.py +179 -0
- focus_data_toolkit/convert/detect.py +39 -0
- focus_data_toolkit/convert/invoice_detail.py +199 -0
- focus_data_toolkit/convert/streaming.py +1030 -0
- focus_data_toolkit/errors.py +145 -0
- focus_data_toolkit/focus_json.py +68 -0
- focus_data_toolkit/generators/__init__.py +61 -0
- focus_data_toolkit/generators/_shim.py +43 -0
- focus_data_toolkit/generators/engine/__init__.py +14 -0
- focus_data_toolkit/generators/engine/context.py +12 -0
- focus_data_toolkit/generators/engine/determinism.py +117 -0
- focus_data_toolkit/generators/engine/json_focus.py +63 -0
- focus_data_toolkit/generators/engine/ladder.py +71 -0
- focus_data_toolkit/generators/engine/scenarios_core.py +380 -0
- focus_data_toolkit/generators/engine/serialize.py +151 -0
- focus_data_toolkit/generators/generate_aws_focus_1_2.py +19 -0
- focus_data_toolkit/generators/generate_aws_focus_1_3.py +20 -0
- focus_data_toolkit/generators/generate_azure_focus_1_2.py +17 -0
- focus_data_toolkit/generators/generate_azure_focus_1_3.py +17 -0
- focus_data_toolkit/generators/generate_gcp_focus_1_2.py +17 -0
- focus_data_toolkit/generators/generate_gcp_focus_1_3.py +17 -0
- focus_data_toolkit/generators/providers/__init__.py +29 -0
- focus_data_toolkit/generators/providers/aws.py +186 -0
- focus_data_toolkit/generators/providers/azure.py +191 -0
- focus_data_toolkit/generators/providers/gcp.py +194 -0
- focus_data_toolkit/generators/providers/profile.py +123 -0
- focus_data_toolkit/generators/scenarios.py +178 -0
- focus_data_toolkit/generators/versions/__init__.py +17 -0
- focus_data_toolkit/generators/versions/adapter.py +41 -0
- focus_data_toolkit/generators/versions/v1_2.py +111 -0
- focus_data_toolkit/generators/versions/v1_3.py +154 -0
- focus_data_toolkit/io/__init__.py +1 -0
- focus_data_toolkit/io/atomic_writer.py +462 -0
- focus_data_toolkit/io/csv_io.py +128 -0
- focus_data_toolkit/io/parquet_io.py +528 -0
- focus_data_toolkit/io/records.py +92 -0
- focus_data_toolkit/io/row_source.py +117 -0
- focus_data_toolkit/lifecycle.py +342 -0
- focus_data_toolkit/manifest.py +114 -0
- focus_data_toolkit/model/__init__.py +43 -0
- focus_data_toolkit/model/capabilities.py +66 -0
- focus_data_toolkit/model/focus_1_4_decimal_scale.json +10 -0
- focus_data_toolkit/model/focus_1_4_model.json +1913 -0
- focus_data_toolkit/model/focus_1_4_servicesubcategory.json +84 -0
- focus_data_toolkit/model/focus_json_keys.py +112 -0
- focus_data_toolkit/model/iso_4217_currencies.json +23 -0
- focus_data_toolkit/model/json_schema_check.py +205 -0
- focus_data_toolkit/model/json_schemas/allocatedmethoddetailsobjectschema.json +82 -0
- focus_data_toolkit/model/json_schemas/commitmentprogrameligibilitydetailsobjectschema.json +41 -0
- focus_data_toolkit/model/json_schemas/contractappliedobjectschema.json +104 -0
- focus_data_toolkit/model/json_schemas/contractcommitmentapplicabilityobjectschema.json +290 -0
- focus_data_toolkit/model/json_schemas/json_schemas_provenance.json +38 -0
- focus_data_toolkit/model/model_provenance.json +58 -0
- focus_data_toolkit/model/validator.py +498 -0
- focus_data_toolkit/modes.py +18 -0
- focus_data_toolkit/official_validator.py +61 -0
- focus_data_toolkit/progress.py +89 -0
- focus_data_toolkit/provenance.py +106 -0
- focus_data_toolkit/py.typed +1 -0
- focus_data_toolkit/runtime.py +243 -0
- focus_data_toolkit/schema/__init__.py +17 -0
- focus_data_toolkit/schema/detection.py +274 -0
- focus_data_toolkit/schema/registry.py +127 -0
- focus_data_toolkit/storage/__init__.py +1 -0
- focus_data_toolkit/storage/external_index.py +99 -0
- focus_data_toolkit/storage/spill.py +150 -0
- focus_data_toolkit/studio/__init__.py +19 -0
- focus_data_toolkit/studio/app.py +467 -0
- focus_data_toolkit/studio/config.py +42 -0
- focus_data_toolkit/studio/frontend/app.js +214 -0
- focus_data_toolkit/studio/frontend/index.html +101 -0
- focus_data_toolkit/studio/frontend/style.css +60 -0
- focus_data_toolkit/studio/jobs.py +142 -0
- focus_data_toolkit/studio/preview.py +32 -0
- focus_data_toolkit/studio/security.py +125 -0
- focus_data_toolkit/studio/server.py +71 -0
- focus_data_toolkit/supplement/__init__.py +50 -0
- focus_data_toolkit/supplement/adapters/__init__.py +21 -0
- focus_data_toolkit/supplement/adapters/adapters_provenance.json +39 -0
- focus_data_toolkit/supplement/adapters/aws_invoice_summary.json +24 -0
- focus_data_toolkit/supplement/adapters/aws_savings_plans.json +31 -0
- focus_data_toolkit/supplement/adapters/azure_invoice.json +25 -0
- focus_data_toolkit/supplement/adapters/gcp_compute_commitments.json +28 -0
- focus_data_toolkit/supplement/adapters/registry.py +215 -0
- focus_data_toolkit/supplement/apply.py +318 -0
- focus_data_toolkit/supplement/gaps.py +219 -0
- focus_data_toolkit/supplement/kinds.py +118 -0
- focus_data_toolkit/supplement/loader.py +409 -0
- focus_data_toolkit/supplement/spec.py +74 -0
- focus_data_toolkit/supplement/validate.py +215 -0
- focus_data_toolkit/validate/__init__.py +15 -0
- focus_data_toolkit/validate/allocation.py +333 -0
- focus_data_toolkit/validate/bundle.py +254 -0
- focus_data_toolkit/validate/codes.py +93 -0
- focus_data_toolkit/validate/corrections.py +245 -0
- focus_data_toolkit/validate/reconciliation.py +98 -0
- focus_data_toolkit/validate/referential.py +289 -0
- focus_data_toolkit-0.11.0.dist-info/METADATA +519 -0
- focus_data_toolkit-0.11.0.dist-info/RECORD +116 -0
- focus_data_toolkit-0.11.0.dist-info/WHEEL +5 -0
- focus_data_toolkit-0.11.0.dist-info/entry_points.txt +2 -0
- focus_data_toolkit-0.11.0.dist-info/licenses/LICENSE +21 -0
- focus_data_toolkit-0.11.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
- focus_data_toolkit-0.11.0.dist-info/licenses/NOTICE +60 -0
- focus_data_toolkit-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Security helpers for the local Studio.
|
|
2
|
+
|
|
3
|
+
Even a loopback-only server is reachable by a malicious web page open in the same browser, so the
|
|
4
|
+
Studio defends the surface with (a) a per-start random token required on every API call,
|
|
5
|
+
(b) strict ``Host`` header validation (anti DNS-rebinding), (c) ``Origin`` validation on
|
|
6
|
+
state-changing requests (anti-CSRF), and (d) confinement of all file access to an allowlisted
|
|
7
|
+
root. All checks are standard-library only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hmac
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_token() -> str:
|
|
19
|
+
"""A fresh URL-safe token, generated once per server start."""
|
|
20
|
+
return secrets.token_urlsafe(32)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def token_matches(expected: str, provided: str | None) -> bool:
|
|
24
|
+
"""Constant-time token comparison; ``False`` for a missing/empty token."""
|
|
25
|
+
if not provided:
|
|
26
|
+
return False
|
|
27
|
+
return hmac.compare_digest(expected, provided)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_loopback_host(host: str) -> bool:
|
|
31
|
+
"""Whether a *bind* address is loopback (so remote exposure needs an explicit opt-in)."""
|
|
32
|
+
h = (host or "").strip().lower()
|
|
33
|
+
if h in ("localhost", "::1", "0:0:0:0:0:0:0:1"):
|
|
34
|
+
return True
|
|
35
|
+
return h.startswith("127.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def allowed_authorities(host: str, port: int) -> set[str]:
|
|
39
|
+
"""The set of acceptable ``Host`` header values (``authority`` = host[:port])."""
|
|
40
|
+
hosts = {"127.0.0.1", "localhost", "[::1]", "::1"}
|
|
41
|
+
if host:
|
|
42
|
+
hosts.add(host)
|
|
43
|
+
hosts.add(f"[{host}]")
|
|
44
|
+
out: set[str] = set()
|
|
45
|
+
for h in hosts:
|
|
46
|
+
out.add(h.lower())
|
|
47
|
+
out.add(f"{h}:{port}".lower())
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def host_header_allowed(host_header: str | None, host: str, port: int) -> bool:
|
|
52
|
+
"""Validate the ``Host`` header against the loopback/bind allowlist (anti DNS-rebinding)."""
|
|
53
|
+
if not host_header:
|
|
54
|
+
return False
|
|
55
|
+
return host_header.strip().lower() in allowed_authorities(host, port)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def allowed_origins(host: str, port: int) -> set[str]:
|
|
59
|
+
"""Acceptable ``Origin`` values for state-changing requests (same-origin only)."""
|
|
60
|
+
return {f"http://{authority}" for authority in allowed_authorities(host, port)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def origin_allowed(origin: str | None, host: str, port: int) -> bool:
|
|
64
|
+
"""Validate an ``Origin`` header (anti-CSRF). A missing Origin on a POST is rejected."""
|
|
65
|
+
if not origin:
|
|
66
|
+
return False
|
|
67
|
+
return origin.strip().lower() in allowed_origins(host, port)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PathOutsideRoot(ValueError):
|
|
71
|
+
"""Raised when a requested path escapes the allowlisted root directory."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_within_root(user_path: str, root: Path) -> Path:
|
|
75
|
+
"""Resolve ``user_path`` to an existing entry whose *physical* location stays under ``root``.
|
|
76
|
+
|
|
77
|
+
``root`` is always a server-controlled directory (never user input). The path is walked one
|
|
78
|
+
component at a time and confined on two levels:
|
|
79
|
+
|
|
80
|
+
* **Lexically** — absolute inputs, drive-relative / UNC paths and any ``..`` segment are
|
|
81
|
+
refused, and each component is matched **by name** against the actual entries of the current
|
|
82
|
+
directory (via :func:`os.scandir`), so the user string never participates in path
|
|
83
|
+
construction (only in an equality comparison).
|
|
84
|
+
* **Physically** — the matched entry is canonicalised with :meth:`Path.resolve` (which follows
|
|
85
|
+
symlinks, Windows junctions and reparse points) and must remain *at or under* the canonical
|
|
86
|
+
root (:meth:`Path.is_relative_to`, not a textual prefix check) **before** the walk descends
|
|
87
|
+
into it. A link whose real target escapes the root is therefore rejected — a link that stays
|
|
88
|
+
inside the root is followed and accepted.
|
|
89
|
+
|
|
90
|
+
Nested browsing (``a/b/c.csv``) is fully supported. A component with no matching entry, a broken
|
|
91
|
+
link, a symlink loop or an inaccessible/missing root all raise :class:`PathOutsideRoot`; the
|
|
92
|
+
returned path is fully canonical (all links already resolved). No user-controlled data reaches a
|
|
93
|
+
filesystem operation.
|
|
94
|
+
"""
|
|
95
|
+
if os.path.isabs(user_path) or user_path[:1] in ("/", "\\") or os.path.splitdrive(user_path)[0]:
|
|
96
|
+
raise PathOutsideRoot(f"absolute paths are not allowed: {user_path!r}")
|
|
97
|
+
try:
|
|
98
|
+
base = Path(root).resolve(strict=True)
|
|
99
|
+
except (OSError, RuntimeError) as exc:
|
|
100
|
+
raise PathOutsideRoot("allowed root is not accessible") from exc
|
|
101
|
+
current = base
|
|
102
|
+
for raw in user_path.replace("\\", "/").split("/"):
|
|
103
|
+
if raw in ("", "."):
|
|
104
|
+
continue
|
|
105
|
+
if raw == "..":
|
|
106
|
+
raise PathOutsideRoot(f"path {user_path!r} is outside the allowed root")
|
|
107
|
+
match: Path | None = None
|
|
108
|
+
try:
|
|
109
|
+
with os.scandir(current) as entries:
|
|
110
|
+
for entry in entries:
|
|
111
|
+
if entry.name == raw:
|
|
112
|
+
match = Path(entry.path)
|
|
113
|
+
break
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise PathOutsideRoot(f"cannot resolve {user_path!r} under the allowed root") from exc
|
|
116
|
+
if match is None:
|
|
117
|
+
raise PathOutsideRoot(f"no entry {raw!r} under the allowed root")
|
|
118
|
+
try:
|
|
119
|
+
resolved = match.resolve(strict=True)
|
|
120
|
+
except (OSError, RuntimeError) as exc: # broken link, symlink loop, permission denied, ...
|
|
121
|
+
raise PathOutsideRoot(f"cannot resolve {user_path!r} under the allowed root") from exc
|
|
122
|
+
if not resolved.is_relative_to(base):
|
|
123
|
+
raise PathOutsideRoot(f"path {user_path!r} is outside the allowed root")
|
|
124
|
+
current = resolved
|
|
125
|
+
return current
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Launch the Studio web server (uvicorn), loopback-only unless explicitly allowed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from focus_data_toolkit.studio.app import create_app
|
|
12
|
+
from focus_data_toolkit.studio.config import (
|
|
13
|
+
DEFAULT_MAX_GENERATE_ROWS,
|
|
14
|
+
DEFAULT_MAX_UPLOAD_BYTES,
|
|
15
|
+
DEFAULT_PORT,
|
|
16
|
+
StudioConfig,
|
|
17
|
+
)
|
|
18
|
+
from focus_data_toolkit.studio.security import is_loopback_host
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(
|
|
22
|
+
*,
|
|
23
|
+
host: str = "127.0.0.1",
|
|
24
|
+
port: int = DEFAULT_PORT,
|
|
25
|
+
root: str | Path | None = None,
|
|
26
|
+
work_dir: str | Path | None = None,
|
|
27
|
+
allow_remote: bool = False,
|
|
28
|
+
open_browser: bool = True,
|
|
29
|
+
max_upload_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
|
|
30
|
+
max_generate_rows: int = DEFAULT_MAX_GENERATE_ROWS,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""Start the Studio. Refuses a non-loopback bind unless ``allow_remote`` is set."""
|
|
33
|
+
if not is_loopback_host(host) and not allow_remote:
|
|
34
|
+
print(
|
|
35
|
+
f"error: refusing to bind non-loopback host {host!r} without --allow-remote "
|
|
36
|
+
"(the Studio serves local files; expose it only on trusted networks)",
|
|
37
|
+
file=sys.stderr,
|
|
38
|
+
)
|
|
39
|
+
return 2
|
|
40
|
+
if work_dir is None:
|
|
41
|
+
work_dir = Path(tempfile.mkdtemp(prefix="fdt-studio-"))
|
|
42
|
+
config = StudioConfig(
|
|
43
|
+
host=host,
|
|
44
|
+
port=port,
|
|
45
|
+
root=Path(root) if root else Path.cwd(),
|
|
46
|
+
work_dir=Path(work_dir),
|
|
47
|
+
allow_remote=allow_remote,
|
|
48
|
+
max_upload_bytes=max_upload_bytes,
|
|
49
|
+
max_generate_rows=max_generate_rows,
|
|
50
|
+
)
|
|
51
|
+
app = create_app(config)
|
|
52
|
+
url = config.url()
|
|
53
|
+
print(f"Focus Data Toolkit Studio: {url}")
|
|
54
|
+
print(f" root: {config.root}")
|
|
55
|
+
print(f" work: {config.work_dir}")
|
|
56
|
+
if not is_loopback_host(host):
|
|
57
|
+
print(" WARNING: bound to a non-loopback address — the access token is the only guard.")
|
|
58
|
+
if open_browser:
|
|
59
|
+
threading.Timer(1.0, lambda: _open(url)).start()
|
|
60
|
+
|
|
61
|
+
import uvicorn
|
|
62
|
+
|
|
63
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _open(url: str) -> None:
|
|
68
|
+
try:
|
|
69
|
+
webbrowser.open(url)
|
|
70
|
+
except Exception: # a headless environment has no browser — never fatal
|
|
71
|
+
pass
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Supplemental client data: complete a FOCUS 1.2/1.3 source into factual 1.4 datasets.
|
|
2
|
+
|
|
3
|
+
A Cost and Usage source alone cannot factually populate the provider-issued facts the
|
|
4
|
+
FOCUS 1.4 Billing Period / Invoice Detail / Contract Commitment datasets require. This
|
|
5
|
+
package lets a client supply those facts:
|
|
6
|
+
|
|
7
|
+
* :mod:`focus_data_toolkit.supplement.gaps` — compute exactly which columns are missing
|
|
8
|
+
for a given source (the ``gaps`` CLI command) and which supplement satisfies each;
|
|
9
|
+
* :mod:`focus_data_toolkit.supplement.kinds` — the registry of supplement kinds (their
|
|
10
|
+
target dataset, join keys and allowed fact columns);
|
|
11
|
+
* :mod:`focus_data_toolkit.supplement.adapters` — provider-native adapters that translate
|
|
12
|
+
documented AWS / Azure / GCP export formats into those kinds automatically.
|
|
13
|
+
|
|
14
|
+
Supplied values carry ``ENRICHED`` lineage with full attribution, so a fully covered
|
|
15
|
+
dataset becomes strictly producible without any code change to the strict gate.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from focus_data_toolkit.supplement.gaps import ColumnGap, GapReport, compute_gaps
|
|
21
|
+
from focus_data_toolkit.supplement.kinds import SUPPLEMENT_KINDS, SupplementKind
|
|
22
|
+
from focus_data_toolkit.supplement.loader import SupplementBundle, SupplementTable
|
|
23
|
+
from focus_data_toolkit.supplement.spec import (
|
|
24
|
+
SupplementError,
|
|
25
|
+
SupplementFileSpec,
|
|
26
|
+
load_bundle_dir,
|
|
27
|
+
parse_supplement_arg,
|
|
28
|
+
)
|
|
29
|
+
from focus_data_toolkit.supplement.validate import (
|
|
30
|
+
SourceKeySets,
|
|
31
|
+
source_key_sets,
|
|
32
|
+
validate_supplements,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SUPPLEMENT_KINDS",
|
|
37
|
+
"ColumnGap",
|
|
38
|
+
"GapReport",
|
|
39
|
+
"SourceKeySets",
|
|
40
|
+
"SupplementBundle",
|
|
41
|
+
"SupplementError",
|
|
42
|
+
"SupplementFileSpec",
|
|
43
|
+
"SupplementKind",
|
|
44
|
+
"SupplementTable",
|
|
45
|
+
"compute_gaps",
|
|
46
|
+
"load_bundle_dir",
|
|
47
|
+
"parse_supplement_arg",
|
|
48
|
+
"source_key_sets",
|
|
49
|
+
"validate_supplements",
|
|
50
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Provider-native supplement adapters (AWS / Azure / GCP export formats)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from focus_data_toolkit.supplement.adapters.registry import (
|
|
6
|
+
Adapter,
|
|
7
|
+
AdapterError,
|
|
8
|
+
adapter_provenance,
|
|
9
|
+
detect_adapter,
|
|
10
|
+
get_adapter,
|
|
11
|
+
load_adapters,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Adapter",
|
|
16
|
+
"AdapterError",
|
|
17
|
+
"adapter_provenance",
|
|
18
|
+
"detect_adapter",
|
|
19
|
+
"get_adapter",
|
|
20
|
+
"load_adapters",
|
|
21
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Provenance of the vendored provider-native supplement adapters.",
|
|
3
|
+
"attribution": "Field names are derived from the official provider API documentation and the referenced open-source service models (botocore, Apache-2.0). Mapping tables authored for focus-data-toolkit.",
|
|
4
|
+
"retrieved": "2026-07-17",
|
|
5
|
+
"adapters": [
|
|
6
|
+
{
|
|
7
|
+
"file": "aws_invoice_summary.json",
|
|
8
|
+
"adapter": "aws-invoice-summary",
|
|
9
|
+
"target_kind": "invoice",
|
|
10
|
+
"source": "AWS Invoicing API \u2014 InvoiceSummary (ListInvoiceSummaries)",
|
|
11
|
+
"doc_url": "https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_invoicing_InvoiceSummary.html",
|
|
12
|
+
"sha256": "f69d31914694e9cfd7f9ec3be3aff273d2340a4f8368285d86c31cb6963ee0ae"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"file": "aws_savings_plans.json",
|
|
16
|
+
"adapter": "aws-savings-plans",
|
|
17
|
+
"target_kind": "contract_commitment",
|
|
18
|
+
"source": "AWS Savings Plans API \u2014 SavingsPlan (DescribeSavingsPlans)",
|
|
19
|
+
"doc_url": "https://docs.aws.amazon.com/savingsplans/latest/APIReference/API_SavingsPlan.html",
|
|
20
|
+
"sha256": "baf099ba17753672c52ba504d9deef978c279c4a168c8be72b05501a878df6fb"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"file": "azure_invoice.json",
|
|
24
|
+
"adapter": "azure-invoice",
|
|
25
|
+
"target_kind": "invoice",
|
|
26
|
+
"source": "Azure Billing \u2014 Invoices REST API (Invoice resource)",
|
|
27
|
+
"doc_url": "https://learn.microsoft.com/en-us/rest/api/billing/invoices/get?view=rest-billing-2024-04-01",
|
|
28
|
+
"sha256": "3539beba9d84cab5ae7fa8f7e2c35fb32d3c333e85a01ff887748ad6a4780e85"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"file": "gcp_compute_commitments.json",
|
|
32
|
+
"adapter": "gcp-compute-commitments",
|
|
33
|
+
"target_kind": "contract_commitment",
|
|
34
|
+
"source": "Google Compute Engine \u2014 regionCommitments (Commitment resource)",
|
|
35
|
+
"doc_url": "https://docs.cloud.google.com/compute/docs/reference/rest/v1/regionCommitments",
|
|
36
|
+
"sha256": "8adc15b222114ef6f90deca05aad448018b56a2a7214c4013a9e36039bb34ac3"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adapter": "aws-invoice-summary",
|
|
3
|
+
"version": "1",
|
|
4
|
+
"target_kind": "invoice",
|
|
5
|
+
"provenance": {
|
|
6
|
+
"source": "AWS Invoicing API — InvoiceSummary (ListInvoiceSummaries)",
|
|
7
|
+
"doc_url": "https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_invoicing_InvoiceSummary.html",
|
|
8
|
+
"field_names_from": "botocore invoicing service model, apiVersion 2024-12-01",
|
|
9
|
+
"retrieved": "2026-07-17",
|
|
10
|
+
"notes": "Invoice-header facts only. AWS does not expose payment terms on the invoice summary, so PaymentTerms is left for the client (coverage reporting shows the residual gap). Rows returned by the API are issued invoices, so InvoiceIssueStatus is the invariant 'Issued'. JSON nested fields are addressed by dot-path (Entity.InvoicingEntity, BillingPeriod.Month)."
|
|
11
|
+
},
|
|
12
|
+
"detect": {
|
|
13
|
+
"all_of": ["InvoiceId", "IssuedDate"],
|
|
14
|
+
"any_of": ["DueDate", "InvoiceType", "Entity.InvoicingEntity", "PurchaseOrderNumber"]
|
|
15
|
+
},
|
|
16
|
+
"fields": [
|
|
17
|
+
{"target": "InvoiceId", "source": "InvoiceId"},
|
|
18
|
+
{"target": "InvoiceIssuerName", "source": "Entity.InvoicingEntity"},
|
|
19
|
+
{"target": "InvoiceIssueDate", "source": "IssuedDate", "transform": "date_to_utc"},
|
|
20
|
+
{"target": "PaymentDueDate", "source": "DueDate", "transform": "date_to_utc"},
|
|
21
|
+
{"target": "PurchaseOrderNumber", "source": "PurchaseOrderNumber"},
|
|
22
|
+
{"target": "InvoiceIssueStatus", "const": "Issued"}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adapter": "aws-savings-plans",
|
|
3
|
+
"version": "1",
|
|
4
|
+
"target_kind": "contract_commitment",
|
|
5
|
+
"provenance": {
|
|
6
|
+
"source": "AWS Savings Plans API — SavingsPlan (DescribeSavingsPlans)",
|
|
7
|
+
"doc_url": "https://docs.aws.amazon.com/savingsplans/latest/APIReference/API_SavingsPlan.html",
|
|
8
|
+
"field_names_from": "botocore savingsplans service model, apiVersion 2019-06-28",
|
|
9
|
+
"retrieved": "2026-07-17",
|
|
10
|
+
"notes": "Maps only fields genuinely determinable from a Savings Plans inventory. paymentOption uses the same vocabulary as FOCUS ContractCommitmentPaymentModel. The three constants are invariant facts of the Savings Plans product: it is a discount commitment (BenefitCategory=Discount), applies continuously to eligible usage (Model=Continuous), and commits to an hourly spend rate (FulfillmentInterval=Hourly). ContractCommitmentLastUpdated and ContractCommitmentApplicability are NOT emitted (not present in the export) — the client supplies them and coverage reporting shows the residual gap. OfferCategory is left out because a Savings Plan may be public or under a negotiated agreement, which the export does not distinguish."
|
|
11
|
+
},
|
|
12
|
+
"detect": {
|
|
13
|
+
"all_of": ["savingsPlanId", "paymentOption"],
|
|
14
|
+
"any_of": ["state", "savingsPlanType", "commitment", "start"]
|
|
15
|
+
},
|
|
16
|
+
"fields": [
|
|
17
|
+
{"target": "ContractCommitmentId", "source": "savingsPlanId"},
|
|
18
|
+
{"target": "ContractCommitmentPaymentModel", "source": "paymentOption",
|
|
19
|
+
"value_map": {"No Upfront": "No Upfront", "Partial Upfront": "Partial Upfront", "All Upfront": "All Upfront"}},
|
|
20
|
+
{"target": "ContractCommitmentPaymentInterval", "source": "paymentOption",
|
|
21
|
+
"value_map": {"All Upfront": "One-Time", "Partial Upfront": "Monthly", "No Upfront": "Monthly"}},
|
|
22
|
+
{"target": "ContractCommitmentLifecycleStatus", "source": "state",
|
|
23
|
+
"value_map": {"active": "Active", "payment-pending": "Pending", "payment-failed": "Pending",
|
|
24
|
+
"queued": "Proposed", "queued-deleted": "Canceled", "retired": "Expired",
|
|
25
|
+
"pending-return": "Active", "returned": "Canceled"}},
|
|
26
|
+
{"target": "ContractCommitmentCreated", "source": "start", "transform": "date_to_utc"},
|
|
27
|
+
{"target": "ContractCommitmentBenefitCategory", "const": "Discount"},
|
|
28
|
+
{"target": "ContractCommitmentModel", "const": "Continuous"},
|
|
29
|
+
{"target": "ContractCommitmentFulfillmentInterval", "const": "Hourly"}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adapter": "azure-invoice",
|
|
3
|
+
"version": "1",
|
|
4
|
+
"target_kind": "invoice",
|
|
5
|
+
"provenance": {
|
|
6
|
+
"source": "Azure Billing — Invoices REST API (Invoice resource)",
|
|
7
|
+
"doc_url": "https://learn.microsoft.com/en-us/rest/api/billing/invoices/get?view=rest-billing-2024-04-01",
|
|
8
|
+
"field_names_from": "Azure Billing REST API 2024-04-01 Invoice.properties; InvoiceStatus enum Due/OverDue/Paid/Void",
|
|
9
|
+
"retrieved": "2026-07-17",
|
|
10
|
+
"notes": "Flatten the invoice JSON so 'properties.*' become dot-path columns; the invoice number is the resource 'name'. Azure InvoiceStatus is a payment status (Due/OverDue/Paid) — all issued — mapped to FOCUS InvoiceIssueStatus 'Issued', with 'Void' -> 'Voided'. Azure MCA/EA invoices are issued by Microsoft, so InvoiceIssuerName is the constant 'Microsoft'; if a client's FOCUS source uses a different issuer string the join simply does not match (orphan/coverage diagnostics make that visible — never a silent wrong value). PaymentTerms is not exposed on the invoice resource and is left for the client."
|
|
11
|
+
},
|
|
12
|
+
"detect": {
|
|
13
|
+
"all_of": ["name", "properties.status", "properties.invoiceDate"],
|
|
14
|
+
"any_of": ["properties.dueDate", "properties.invoicePeriodStartDate", "properties.billingProfileDisplayName", "properties.invoiceType"]
|
|
15
|
+
},
|
|
16
|
+
"fields": [
|
|
17
|
+
{"target": "InvoiceId", "source": "name"},
|
|
18
|
+
{"target": "InvoiceIssuerName", "const": "Microsoft"},
|
|
19
|
+
{"target": "InvoiceIssueDate", "source": "properties.invoiceDate", "transform": "date_to_utc"},
|
|
20
|
+
{"target": "PaymentDueDate", "source": "properties.dueDate", "transform": "date_to_utc"},
|
|
21
|
+
{"target": "PurchaseOrderNumber", "source": "properties.purchaseOrderNumber"},
|
|
22
|
+
{"target": "InvoiceIssueStatus", "source": "properties.status",
|
|
23
|
+
"value_map": {"Due": "Issued", "OverDue": "Issued", "Paid": "Issued", "Void": "Voided"}}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adapter": "gcp-compute-commitments",
|
|
3
|
+
"version": "1",
|
|
4
|
+
"target_kind": "contract_commitment",
|
|
5
|
+
"provenance": {
|
|
6
|
+
"source": "Google Compute Engine — regionCommitments (Commitment resource)",
|
|
7
|
+
"doc_url": "https://docs.cloud.google.com/compute/docs/reference/rest/v1/regionCommitments",
|
|
8
|
+
"field_names_from": "Compute Engine v1 Commitment resource: name, plan (TWELVE_MONTH/THIRTY_SIX_MONTH), status (CREATING/NOT_YET_ACTIVE/ACTIVE/EXPIRED/CANCELLED), startTimestamp, endTimestamp",
|
|
9
|
+
"retrieved": "2026-07-17",
|
|
10
|
+
"notes": "Shape of 'gcloud compute commitments list --format=json'. The three constants are invariant facts of GCP committed use discounts: they discount usage (BenefitCategory=Discount), apply continuously to eligible usage (Model=Continuous), and commit to an hourly rate (FulfillmentInterval=Hourly). GCP CUDs are billed monthly with no upfront payment, so PaymentModel=No Upfront and PaymentInterval=Monthly. ContractCommitmentLastUpdated and ContractCommitmentApplicability are NOT emitted (not present in the export) — the client supplies them and coverage reporting shows the residual gap. OfferCategory is left out (a commitment may be public or under a negotiated agreement, which the export does not distinguish)."
|
|
11
|
+
},
|
|
12
|
+
"detect": {
|
|
13
|
+
"all_of": ["name", "plan", "startTimestamp"],
|
|
14
|
+
"any_of": ["endTimestamp", "status", "region"]
|
|
15
|
+
},
|
|
16
|
+
"fields": [
|
|
17
|
+
{"target": "ContractCommitmentId", "source": "name"},
|
|
18
|
+
{"target": "ContractCommitmentCreated", "source": "startTimestamp", "transform": "date_to_utc"},
|
|
19
|
+
{"target": "ContractCommitmentLifecycleStatus", "source": "status",
|
|
20
|
+
"value_map": {"ACTIVE": "Active", "EXPIRED": "Expired", "NOT_YET_ACTIVE": "Proposed",
|
|
21
|
+
"CREATING": "Pending", "CANCELLED": "Canceled", "CANCELED": "Canceled"}},
|
|
22
|
+
{"target": "ContractCommitmentPaymentModel", "const": "No Upfront"},
|
|
23
|
+
{"target": "ContractCommitmentPaymentInterval", "const": "Monthly"},
|
|
24
|
+
{"target": "ContractCommitmentBenefitCategory", "const": "Discount"},
|
|
25
|
+
{"target": "ContractCommitmentModel", "const": "Continuous"},
|
|
26
|
+
{"target": "ContractCommitmentFulfillmentInterval", "const": "Hourly"}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Provider-native supplement adapters — translate documented cloud export formats.
|
|
2
|
+
|
|
3
|
+
The primary client journey is a cloud-provider customer holding FOCUS 1.2/1.3 **plus
|
|
4
|
+
native provider artifacts** (invoice exports, reservation/commitment inventories) from
|
|
5
|
+
AWS, Azure or GCP — not hand-authored FOCUS-named files. An **adapter** recognizes such a
|
|
6
|
+
format by its native field names and translates each row into a canonical supplement kind
|
|
7
|
+
(see :mod:`focus_data_toolkit.supplement.kinds`). The translated rows then flow through the
|
|
8
|
+
*unchanged* supplement validation — adapters never bypass it.
|
|
9
|
+
|
|
10
|
+
An adapter is **data, not code**: a vendored JSON mapping table (``adapters/*.json``) with a
|
|
11
|
+
provenance block (official-doc URL, the model artifact the field names came from, retrieval
|
|
12
|
+
date). The mapping only ever claims fields the vendored table describes; a value it cannot
|
|
13
|
+
determine is simply not emitted (the client supplies it, and coverage reporting shows the
|
|
14
|
+
residual gap). A file matching no adapter falls back to the generic FOCUS-named path.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from functools import lru_cache
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from focus_data_toolkit.supplement.kinds import SUPPLEMENT_KINDS
|
|
29
|
+
from focus_data_toolkit.supplement.spec import SupplementError
|
|
30
|
+
|
|
31
|
+
ADAPTERS_DIR = Path(__file__).resolve().parent
|
|
32
|
+
PROVENANCE_FILENAME = "adapters_provenance.json"
|
|
33
|
+
|
|
34
|
+
# FOCUS Date/Time: YYYY-MM-DDTHH:mm:ss[.fff]Z (UTC). We normalize provider timestamps to it.
|
|
35
|
+
_DATE_ONLY = re.compile(r"\d{4}-\d{2}-\d{2}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AdapterError(SupplementError):
|
|
39
|
+
"""An adapter mapping table is malformed or produces an invalid row."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _to_utc_datetime(value: str) -> str:
|
|
43
|
+
"""Normalize a provider timestamp to the FOCUS UTC form; pass through if unparseable.
|
|
44
|
+
|
|
45
|
+
An unparseable value is returned unchanged so the supplement validator flags it with a
|
|
46
|
+
precise diagnostic rather than the adapter silently dropping or guessing it.
|
|
47
|
+
"""
|
|
48
|
+
text = value.strip()
|
|
49
|
+
if not text:
|
|
50
|
+
return ""
|
|
51
|
+
if _DATE_ONLY.fullmatch(text):
|
|
52
|
+
return f"{text}T00:00:00Z"
|
|
53
|
+
candidate = text.replace("Z", "+00:00")
|
|
54
|
+
try:
|
|
55
|
+
dt = datetime.fromisoformat(candidate)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return text
|
|
58
|
+
# Emit UTC 'Z'. Aware -> convert to UTC; naive -> assume already UTC.
|
|
59
|
+
if dt.tzinfo is not None:
|
|
60
|
+
dt = dt.astimezone(UTC).replace(tzinfo=None)
|
|
61
|
+
micro = f".{dt.microsecond:06d}".rstrip("0") if dt.microsecond else ""
|
|
62
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S") + micro + "Z"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_TRANSFORMS = {
|
|
66
|
+
"date_to_utc": _to_utc_datetime,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class FieldMapping:
|
|
72
|
+
"""How one FOCUS target column is produced from the native row."""
|
|
73
|
+
|
|
74
|
+
target: str
|
|
75
|
+
source: str | None = None # native field name (dot-path for nested JSON)
|
|
76
|
+
const: str | None = None # fixed value (an invariant fact of the product type)
|
|
77
|
+
value_map: Mapping[str, str] | None = None # native value -> FOCUS value
|
|
78
|
+
default: str | None = None # value_map fallback (None = drop when unmapped)
|
|
79
|
+
transform: str | None = None # a name in _TRANSFORMS applied to the source value
|
|
80
|
+
|
|
81
|
+
def produce(self, row: Mapping[str, str]) -> str | None:
|
|
82
|
+
if self.const is not None:
|
|
83
|
+
return self.const
|
|
84
|
+
assert self.source is not None
|
|
85
|
+
raw = (row.get(self.source) or "").strip()
|
|
86
|
+
if self.value_map is not None:
|
|
87
|
+
if not raw:
|
|
88
|
+
return None
|
|
89
|
+
mapped = self.value_map.get(raw, self.default)
|
|
90
|
+
return mapped # None -> unmapped, drop (client supplies / coverage gap)
|
|
91
|
+
if self.transform:
|
|
92
|
+
raw = _TRANSFORMS[self.transform](raw)
|
|
93
|
+
return raw or None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Adapter:
|
|
98
|
+
"""A vendored translation from one native provider export to a supplement kind."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
version: str
|
|
102
|
+
target_kind: str
|
|
103
|
+
detect_all_of: tuple[str, ...]
|
|
104
|
+
detect_any_of: tuple[str, ...]
|
|
105
|
+
fields: tuple[FieldMapping, ...]
|
|
106
|
+
provenance: Mapping[str, str]
|
|
107
|
+
|
|
108
|
+
def matches(self, header: Sequence[str]) -> bool:
|
|
109
|
+
present = set(header)
|
|
110
|
+
if not set(self.detect_all_of) <= present:
|
|
111
|
+
return False
|
|
112
|
+
return not self.detect_any_of or bool(present & set(self.detect_any_of))
|
|
113
|
+
|
|
114
|
+
def translate(self, rows: Sequence[Mapping[str, str]]) -> list[dict[str, str]]:
|
|
115
|
+
"""Translate native rows into canonical-kind rows (FOCUS column names)."""
|
|
116
|
+
out: list[dict[str, str]] = []
|
|
117
|
+
for row in rows:
|
|
118
|
+
translated: dict[str, str] = {}
|
|
119
|
+
for field in self.fields:
|
|
120
|
+
value = field.produce(row)
|
|
121
|
+
if value is not None and value != "":
|
|
122
|
+
translated[field.target] = value
|
|
123
|
+
out.append(translated)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def source_tag(self) -> str:
|
|
128
|
+
return f"{self.name}@{self.version}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_adapter(name: str, data: dict) -> Adapter:
|
|
132
|
+
try:
|
|
133
|
+
kind = data["target_kind"]
|
|
134
|
+
if kind not in SUPPLEMENT_KINDS:
|
|
135
|
+
raise AdapterError(f"adapter {name!r}: unknown target_kind {kind!r}")
|
|
136
|
+
fields = tuple(
|
|
137
|
+
FieldMapping(
|
|
138
|
+
target=f["target"],
|
|
139
|
+
source=f.get("source"),
|
|
140
|
+
const=f.get("const"),
|
|
141
|
+
value_map=f.get("value_map"),
|
|
142
|
+
default=f.get("default"),
|
|
143
|
+
transform=f.get("transform"),
|
|
144
|
+
)
|
|
145
|
+
for f in data["fields"]
|
|
146
|
+
)
|
|
147
|
+
adapter = Adapter(
|
|
148
|
+
name=data["adapter"],
|
|
149
|
+
version=str(data["version"]),
|
|
150
|
+
target_kind=kind,
|
|
151
|
+
detect_all_of=tuple(data["detect"].get("all_of", ())),
|
|
152
|
+
detect_any_of=tuple(data["detect"].get("any_of", ())),
|
|
153
|
+
fields=fields,
|
|
154
|
+
provenance=data.get("provenance", {}),
|
|
155
|
+
)
|
|
156
|
+
except (KeyError, TypeError) as exc:
|
|
157
|
+
raise AdapterError(f"adapter {name!r}: malformed mapping table ({exc})") from exc
|
|
158
|
+
# The adapter must produce its kind's join keys.
|
|
159
|
+
produced = {f.target for f in adapter.fields}
|
|
160
|
+
missing = [k for k in SUPPLEMENT_KINDS[kind].join_keys if k not in produced]
|
|
161
|
+
if missing:
|
|
162
|
+
raise AdapterError(
|
|
163
|
+
f"adapter {adapter.name!r} does not produce join key(s) {', '.join(missing)}"
|
|
164
|
+
)
|
|
165
|
+
# Every transform / target must be known.
|
|
166
|
+
for field in adapter.fields:
|
|
167
|
+
if field.transform and field.transform not in _TRANSFORMS:
|
|
168
|
+
raise AdapterError(
|
|
169
|
+
f"adapter {adapter.name!r}: unknown transform {field.transform!r}"
|
|
170
|
+
)
|
|
171
|
+
return adapter
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@lru_cache(maxsize=1)
|
|
175
|
+
def load_adapters() -> dict[str, Adapter]:
|
|
176
|
+
"""Load every vendored adapter mapping table (keyed by adapter name)."""
|
|
177
|
+
adapters: dict[str, Adapter] = {}
|
|
178
|
+
for path in sorted(ADAPTERS_DIR.glob("*.json")):
|
|
179
|
+
if path.name == PROVENANCE_FILENAME:
|
|
180
|
+
continue
|
|
181
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
182
|
+
adapter = _parse_adapter(path.stem, data)
|
|
183
|
+
adapters[adapter.name] = adapter
|
|
184
|
+
return adapters
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_adapter(name: str) -> Adapter:
|
|
188
|
+
adapter = load_adapters().get(name)
|
|
189
|
+
if adapter is None:
|
|
190
|
+
known = ", ".join(sorted(load_adapters())) or "(none)"
|
|
191
|
+
raise AdapterError(f"unknown adapter {name!r}; known adapters: {known}")
|
|
192
|
+
return adapter
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def detect_adapter(header: Sequence[str]) -> Adapter | None:
|
|
196
|
+
"""Return the single adapter matching ``header``; ambiguity raises, no match is ``None``."""
|
|
197
|
+
matches = [a for a in load_adapters().values() if a.matches(header)]
|
|
198
|
+
if len(matches) == 1:
|
|
199
|
+
return matches[0]
|
|
200
|
+
if not matches:
|
|
201
|
+
return None
|
|
202
|
+
names = ", ".join(sorted(a.name for a in matches))
|
|
203
|
+
raise AdapterError(
|
|
204
|
+
f"provider export header is ambiguous between adapters: {names}; "
|
|
205
|
+
"force one with FILE:<adapter-name>"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def adapter_provenance() -> dict:
|
|
210
|
+
"""The vendored adapter provenance manifest (source docs + hashes)."""
|
|
211
|
+
return json.loads((ADAPTERS_DIR / PROVENANCE_FILENAME).read_text(encoding="utf-8"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sha256_of(path: Path) -> str:
|
|
215
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|