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.
Files changed (116) hide show
  1. focus_data_toolkit/__init__.py +69 -0
  2. focus_data_toolkit/__main__.py +6 -0
  3. focus_data_toolkit/_version.py +8 -0
  4. focus_data_toolkit/cli.py +968 -0
  5. focus_data_toolkit/context/__init__.py +88 -0
  6. focus_data_toolkit/context/billing.py +54 -0
  7. focus_data_toolkit/context/provider.py +90 -0
  8. focus_data_toolkit/convert/__init__.py +708 -0
  9. focus_data_toolkit/convert/billing_period.py +65 -0
  10. focus_data_toolkit/convert/contract_applied.py +235 -0
  11. focus_data_toolkit/convert/contract_commitment.py +182 -0
  12. focus_data_toolkit/convert/cost_and_usage.py +179 -0
  13. focus_data_toolkit/convert/detect.py +39 -0
  14. focus_data_toolkit/convert/invoice_detail.py +199 -0
  15. focus_data_toolkit/convert/streaming.py +1030 -0
  16. focus_data_toolkit/errors.py +145 -0
  17. focus_data_toolkit/focus_json.py +68 -0
  18. focus_data_toolkit/generators/__init__.py +61 -0
  19. focus_data_toolkit/generators/_shim.py +43 -0
  20. focus_data_toolkit/generators/engine/__init__.py +14 -0
  21. focus_data_toolkit/generators/engine/context.py +12 -0
  22. focus_data_toolkit/generators/engine/determinism.py +117 -0
  23. focus_data_toolkit/generators/engine/json_focus.py +63 -0
  24. focus_data_toolkit/generators/engine/ladder.py +71 -0
  25. focus_data_toolkit/generators/engine/scenarios_core.py +380 -0
  26. focus_data_toolkit/generators/engine/serialize.py +151 -0
  27. focus_data_toolkit/generators/generate_aws_focus_1_2.py +19 -0
  28. focus_data_toolkit/generators/generate_aws_focus_1_3.py +20 -0
  29. focus_data_toolkit/generators/generate_azure_focus_1_2.py +17 -0
  30. focus_data_toolkit/generators/generate_azure_focus_1_3.py +17 -0
  31. focus_data_toolkit/generators/generate_gcp_focus_1_2.py +17 -0
  32. focus_data_toolkit/generators/generate_gcp_focus_1_3.py +17 -0
  33. focus_data_toolkit/generators/providers/__init__.py +29 -0
  34. focus_data_toolkit/generators/providers/aws.py +186 -0
  35. focus_data_toolkit/generators/providers/azure.py +191 -0
  36. focus_data_toolkit/generators/providers/gcp.py +194 -0
  37. focus_data_toolkit/generators/providers/profile.py +123 -0
  38. focus_data_toolkit/generators/scenarios.py +178 -0
  39. focus_data_toolkit/generators/versions/__init__.py +17 -0
  40. focus_data_toolkit/generators/versions/adapter.py +41 -0
  41. focus_data_toolkit/generators/versions/v1_2.py +111 -0
  42. focus_data_toolkit/generators/versions/v1_3.py +154 -0
  43. focus_data_toolkit/io/__init__.py +1 -0
  44. focus_data_toolkit/io/atomic_writer.py +462 -0
  45. focus_data_toolkit/io/csv_io.py +128 -0
  46. focus_data_toolkit/io/parquet_io.py +528 -0
  47. focus_data_toolkit/io/records.py +92 -0
  48. focus_data_toolkit/io/row_source.py +117 -0
  49. focus_data_toolkit/lifecycle.py +342 -0
  50. focus_data_toolkit/manifest.py +114 -0
  51. focus_data_toolkit/model/__init__.py +43 -0
  52. focus_data_toolkit/model/capabilities.py +66 -0
  53. focus_data_toolkit/model/focus_1_4_decimal_scale.json +10 -0
  54. focus_data_toolkit/model/focus_1_4_model.json +1913 -0
  55. focus_data_toolkit/model/focus_1_4_servicesubcategory.json +84 -0
  56. focus_data_toolkit/model/focus_json_keys.py +112 -0
  57. focus_data_toolkit/model/iso_4217_currencies.json +23 -0
  58. focus_data_toolkit/model/json_schema_check.py +205 -0
  59. focus_data_toolkit/model/json_schemas/allocatedmethoddetailsobjectschema.json +82 -0
  60. focus_data_toolkit/model/json_schemas/commitmentprogrameligibilitydetailsobjectschema.json +41 -0
  61. focus_data_toolkit/model/json_schemas/contractappliedobjectschema.json +104 -0
  62. focus_data_toolkit/model/json_schemas/contractcommitmentapplicabilityobjectschema.json +290 -0
  63. focus_data_toolkit/model/json_schemas/json_schemas_provenance.json +38 -0
  64. focus_data_toolkit/model/model_provenance.json +58 -0
  65. focus_data_toolkit/model/validator.py +498 -0
  66. focus_data_toolkit/modes.py +18 -0
  67. focus_data_toolkit/official_validator.py +61 -0
  68. focus_data_toolkit/progress.py +89 -0
  69. focus_data_toolkit/provenance.py +106 -0
  70. focus_data_toolkit/py.typed +1 -0
  71. focus_data_toolkit/runtime.py +243 -0
  72. focus_data_toolkit/schema/__init__.py +17 -0
  73. focus_data_toolkit/schema/detection.py +274 -0
  74. focus_data_toolkit/schema/registry.py +127 -0
  75. focus_data_toolkit/storage/__init__.py +1 -0
  76. focus_data_toolkit/storage/external_index.py +99 -0
  77. focus_data_toolkit/storage/spill.py +150 -0
  78. focus_data_toolkit/studio/__init__.py +19 -0
  79. focus_data_toolkit/studio/app.py +467 -0
  80. focus_data_toolkit/studio/config.py +42 -0
  81. focus_data_toolkit/studio/frontend/app.js +214 -0
  82. focus_data_toolkit/studio/frontend/index.html +101 -0
  83. focus_data_toolkit/studio/frontend/style.css +60 -0
  84. focus_data_toolkit/studio/jobs.py +142 -0
  85. focus_data_toolkit/studio/preview.py +32 -0
  86. focus_data_toolkit/studio/security.py +125 -0
  87. focus_data_toolkit/studio/server.py +71 -0
  88. focus_data_toolkit/supplement/__init__.py +50 -0
  89. focus_data_toolkit/supplement/adapters/__init__.py +21 -0
  90. focus_data_toolkit/supplement/adapters/adapters_provenance.json +39 -0
  91. focus_data_toolkit/supplement/adapters/aws_invoice_summary.json +24 -0
  92. focus_data_toolkit/supplement/adapters/aws_savings_plans.json +31 -0
  93. focus_data_toolkit/supplement/adapters/azure_invoice.json +25 -0
  94. focus_data_toolkit/supplement/adapters/gcp_compute_commitments.json +28 -0
  95. focus_data_toolkit/supplement/adapters/registry.py +215 -0
  96. focus_data_toolkit/supplement/apply.py +318 -0
  97. focus_data_toolkit/supplement/gaps.py +219 -0
  98. focus_data_toolkit/supplement/kinds.py +118 -0
  99. focus_data_toolkit/supplement/loader.py +409 -0
  100. focus_data_toolkit/supplement/spec.py +74 -0
  101. focus_data_toolkit/supplement/validate.py +215 -0
  102. focus_data_toolkit/validate/__init__.py +15 -0
  103. focus_data_toolkit/validate/allocation.py +333 -0
  104. focus_data_toolkit/validate/bundle.py +254 -0
  105. focus_data_toolkit/validate/codes.py +93 -0
  106. focus_data_toolkit/validate/corrections.py +245 -0
  107. focus_data_toolkit/validate/reconciliation.py +98 -0
  108. focus_data_toolkit/validate/referential.py +289 -0
  109. focus_data_toolkit-0.11.0.dist-info/METADATA +519 -0
  110. focus_data_toolkit-0.11.0.dist-info/RECORD +116 -0
  111. focus_data_toolkit-0.11.0.dist-info/WHEEL +5 -0
  112. focus_data_toolkit-0.11.0.dist-info/entry_points.txt +2 -0
  113. focus_data_toolkit-0.11.0.dist-info/licenses/LICENSE +21 -0
  114. focus_data_toolkit-0.11.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
  115. focus_data_toolkit-0.11.0.dist-info/licenses/NOTICE +60 -0
  116. 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))