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,88 @@
|
|
|
1
|
+
"""Billing and provider context objects, determined per row (never from the first row)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from focus_data_toolkit.context.billing import (
|
|
8
|
+
BillingContext,
|
|
9
|
+
billing_context_of_row,
|
|
10
|
+
distinct_billing_contexts,
|
|
11
|
+
)
|
|
12
|
+
from focus_data_toolkit.context.provider import (
|
|
13
|
+
ProviderContext,
|
|
14
|
+
distinct_provider_contexts,
|
|
15
|
+
provider_context_of_row,
|
|
16
|
+
representative_from_contexts,
|
|
17
|
+
representative_provider,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Cap on how many distinct values a summary lists inline (keeps the manifest bounded).
|
|
21
|
+
_SAMPLE_CAP = 25
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _capped(values: list[str]) -> dict:
|
|
25
|
+
return {"count": len(values), "sample": values[:_SAMPLE_CAP]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def summarize_contexts(
|
|
29
|
+
providers: list[ProviderContext], billing: list[BillingContext]
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Build the bounded context summary from already-distinct provider/billing contexts.
|
|
32
|
+
|
|
33
|
+
Shared by :func:`describe_source_contexts` (eager) and the streaming pipeline, which
|
|
34
|
+
accumulates the distinct contexts incrementally — so both produce an identical summary.
|
|
35
|
+
"""
|
|
36
|
+
issuers = sorted({b.invoice_issuer_name for b in billing if b.invoice_issuer_name})
|
|
37
|
+
accounts = sorted({b.billing_account_id for b in billing if b.billing_account_id})
|
|
38
|
+
currencies = sorted({b.billing_currency for b in billing if b.billing_currency})
|
|
39
|
+
periods = sorted(
|
|
40
|
+
{(b.billing_period_start, b.billing_period_end) for b in billing if b.billing_period_start}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"providers": {
|
|
45
|
+
"count": len(providers),
|
|
46
|
+
"sample": [p.as_dict() for p in providers[:_SAMPLE_CAP]],
|
|
47
|
+
},
|
|
48
|
+
"invoice_issuers": _capped(issuers),
|
|
49
|
+
"billing_accounts": _capped(accounts),
|
|
50
|
+
"billing_currencies": _capped(currencies),
|
|
51
|
+
"billing_periods": {
|
|
52
|
+
"count": len(periods),
|
|
53
|
+
"sample": [list(p) for p in periods[:_SAMPLE_CAP]],
|
|
54
|
+
},
|
|
55
|
+
"multi_provider": len(providers) > 1,
|
|
56
|
+
"multi_issuer": len(issuers) > 1,
|
|
57
|
+
"multi_currency": len(currencies) > 1,
|
|
58
|
+
"multi_period": len(periods) > 1,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def describe_source_contexts(
|
|
63
|
+
rows: Iterable[Mapping[str, str]], source_version: str
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""A bounded, JSON-serialisable summary of the contexts present in a source.
|
|
66
|
+
|
|
67
|
+
Reports distinct providers, issuers, accounts, currencies and periods, and boolean
|
|
68
|
+
``multi_*`` flags — enough for the manifest to show that (e.g.) the source mixed two
|
|
69
|
+
issuers and three currencies, without embedding the full cross-product.
|
|
70
|
+
"""
|
|
71
|
+
rows = list(rows)
|
|
72
|
+
return summarize_contexts(
|
|
73
|
+
distinct_provider_contexts(rows, source_version), distinct_billing_contexts(rows)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"BillingContext",
|
|
79
|
+
"ProviderContext",
|
|
80
|
+
"billing_context_of_row",
|
|
81
|
+
"describe_source_contexts",
|
|
82
|
+
"distinct_billing_contexts",
|
|
83
|
+
"distinct_provider_contexts",
|
|
84
|
+
"provider_context_of_row",
|
|
85
|
+
"representative_from_contexts",
|
|
86
|
+
"representative_provider",
|
|
87
|
+
"summarize_contexts",
|
|
88
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Billing context — issuer, account, currency and period, determined per row.
|
|
2
|
+
|
|
3
|
+
An export can consolidate many billing accounts, currencies, issuers and periods. Grouping
|
|
4
|
+
and enrichment must key on the billing context of each row, never on a single global value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from dataclasses import astuple, dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BillingContext:
|
|
15
|
+
"""The billing identity of a charge line."""
|
|
16
|
+
|
|
17
|
+
invoice_issuer_name: str
|
|
18
|
+
billing_account_id: str
|
|
19
|
+
billing_currency: str
|
|
20
|
+
billing_period_start: str
|
|
21
|
+
billing_period_end: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_complete(self) -> bool:
|
|
25
|
+
return all(astuple(self))
|
|
26
|
+
|
|
27
|
+
def as_dict(self) -> dict[str, str]:
|
|
28
|
+
return {
|
|
29
|
+
"invoice_issuer_name": self.invoice_issuer_name,
|
|
30
|
+
"billing_account_id": self.billing_account_id,
|
|
31
|
+
"billing_currency": self.billing_currency,
|
|
32
|
+
"billing_period_start": self.billing_period_start,
|
|
33
|
+
"billing_period_end": self.billing_period_end,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def billing_context_of_row(row: Mapping[str, str]) -> BillingContext:
|
|
38
|
+
"""Derive the billing context of a single row (empty where a field is absent)."""
|
|
39
|
+
return BillingContext(
|
|
40
|
+
(row.get("InvoiceIssuerName") or "").strip(),
|
|
41
|
+
(row.get("BillingAccountId") or "").strip(),
|
|
42
|
+
(row.get("BillingCurrency") or "").strip(),
|
|
43
|
+
(row.get("BillingPeriodStart") or "").strip(),
|
|
44
|
+
(row.get("BillingPeriodEnd") or "").strip(),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def distinct_billing_contexts(rows: Iterable[Mapping[str, str]]) -> list[BillingContext]:
|
|
49
|
+
"""Return the distinct billing contexts across ``rows`` (deterministically ordered)."""
|
|
50
|
+
seen: dict[tuple[str, ...], BillingContext] = {}
|
|
51
|
+
for row in rows:
|
|
52
|
+
ctx = billing_context_of_row(row)
|
|
53
|
+
seen[astuple(ctx)] = ctx
|
|
54
|
+
return [seen[key] for key in sorted(seen)]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Provider context — service provider vs host provider, determined per row.
|
|
2
|
+
|
|
3
|
+
The service provider (who sells the charge) and the host provider (whose infrastructure it
|
|
4
|
+
runs on) can differ: marketplaces, resellers, MSPs, and third-party services hosted on a
|
|
5
|
+
cloud. This context is therefore a **per-row** property; it must never be inferred once from
|
|
6
|
+
the first row of a file and applied globally.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ProviderContext:
|
|
17
|
+
"""Who sells a charge (service) and whose infrastructure hosts it (host)."""
|
|
18
|
+
|
|
19
|
+
service_provider_name: str
|
|
20
|
+
host_provider_name: str
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_complete(self) -> bool:
|
|
24
|
+
return bool(self.service_provider_name) and bool(self.host_provider_name)
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict[str, str]:
|
|
27
|
+
return {
|
|
28
|
+
"service_provider_name": self.service_provider_name,
|
|
29
|
+
"host_provider_name": self.host_provider_name,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def provider_context_of_row(row: Mapping[str, str], source_version: str) -> ProviderContext:
|
|
34
|
+
"""Derive the provider context of a single Cost and Usage row.
|
|
35
|
+
|
|
36
|
+
A 1.2 source expresses the service provider as ``ProviderName``; 1.3+ uses the
|
|
37
|
+
``ServiceProviderName`` / ``HostProviderName`` split (falling back to the deprecated
|
|
38
|
+
``ProviderName`` if a 1.3 export still carries it). A source that does not expose the
|
|
39
|
+
underlying host gets ``host == service`` — FOCUS requires ``HostProviderName`` to
|
|
40
|
+
match ``ServiceProviderName`` in that case. The deprecated ``PublisherName`` (the
|
|
41
|
+
entity that *produced* the service) is never treated as a host. Absent values stay
|
|
42
|
+
empty (UNAVAILABLE).
|
|
43
|
+
"""
|
|
44
|
+
if source_version == "1.2":
|
|
45
|
+
service = row.get("ProviderName", "") or ""
|
|
46
|
+
host = service
|
|
47
|
+
else:
|
|
48
|
+
service = row.get("ServiceProviderName", "") or row.get("ProviderName", "") or ""
|
|
49
|
+
host = row.get("HostProviderName", "") or service
|
|
50
|
+
return ProviderContext(service.strip(), host.strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def distinct_provider_contexts(
|
|
54
|
+
rows: Iterable[Mapping[str, str]], source_version: str
|
|
55
|
+
) -> list[ProviderContext]:
|
|
56
|
+
"""Return the distinct provider contexts across ``rows`` (deterministically ordered)."""
|
|
57
|
+
seen: dict[tuple[str, str], ProviderContext] = {}
|
|
58
|
+
for row in rows:
|
|
59
|
+
ctx = provider_context_of_row(row, source_version)
|
|
60
|
+
seen[(ctx.service_provider_name, ctx.host_provider_name)] = ctx
|
|
61
|
+
return [seen[key] for key in sorted(seen)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def representative_from_contexts(
|
|
65
|
+
contexts: list[ProviderContext],
|
|
66
|
+
) -> tuple[ProviderContext, bool]:
|
|
67
|
+
"""Choose a representative provider from already-distinct contexts (see below)."""
|
|
68
|
+
if not contexts:
|
|
69
|
+
return ProviderContext("", ""), False
|
|
70
|
+
# Prefer a usable representative: a fully complete context, else one with a non-empty
|
|
71
|
+
# service provider, else the first. This avoids enriching with a blank provider (which
|
|
72
|
+
# would fail the lint) when the same source also carries a complete provider elsewhere.
|
|
73
|
+
chosen = (
|
|
74
|
+
next((c for c in contexts if c.is_complete), None)
|
|
75
|
+
or next((c for c in contexts if c.service_provider_name), None)
|
|
76
|
+
or contexts[0]
|
|
77
|
+
)
|
|
78
|
+
return chosen, len(contexts) > 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def representative_provider(
|
|
82
|
+
rows: Iterable[Mapping[str, str]], source_version: str
|
|
83
|
+
) -> tuple[ProviderContext, bool]:
|
|
84
|
+
"""Return ``(context, ambiguous)`` — a usable representative and whether >1 exist.
|
|
85
|
+
|
|
86
|
+
Used only where a single value must be chosen for enrichment (e.g. synthetic Contract
|
|
87
|
+
Commitment, which the 1.3 source leaves without a provider). ``ambiguous`` is surfaced to
|
|
88
|
+
the caller so the choice is never silent.
|
|
89
|
+
"""
|
|
90
|
+
return representative_from_contexts(distinct_provider_contexts(rows, source_version))
|