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,39 @@
1
+ """Detect the FOCUS version of a Cost and Usage source from its column set.
2
+
3
+ This is a thin compatibility wrapper over :mod:`focus_data_toolkit.schema.detection`,
4
+ which does the real work (dataset + version + confidence). ``detect_focus_version`` keeps
5
+ the historical contract: it returns ``"1.2"`` or ``"1.3"`` for a convertible Cost and Usage
6
+ header and raises ``ValueError`` otherwise.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from focus_data_toolkit.schema.detection import detect_focus_schema
14
+
15
+ _CONVERTIBLE_VERSIONS = ("1.2", "1.3")
16
+
17
+
18
+ def detect_focus_version(fieldnames: Iterable[str]) -> str:
19
+ """Return ``"1.2"`` or ``"1.3"`` for a convertible Cost and Usage header row.
20
+
21
+ Raises ``ValueError`` when the header is not a confidently-identified FOCUS 1.2/1.3
22
+ Cost and Usage table (e.g. FOCUS 1.0/1.1, 1.4, a different dataset, or non-FOCUS data).
23
+ """
24
+ result = detect_focus_schema(fieldnames)
25
+ if (
26
+ result.dataset == "Cost and Usage"
27
+ and result.detected_version in _CONVERTIBLE_VERSIONS
28
+ and result.confidence != "LOW"
29
+ ):
30
+ return result.detected_version
31
+
32
+ detail = (
33
+ f"detected {result.dataset or 'no FOCUS dataset'} "
34
+ f"{result.detected_version or ''} (confidence {result.confidence})".strip()
35
+ )
36
+ raise ValueError(
37
+ "unsupported source: not a FOCUS 1.2 or 1.3 Cost and Usage header "
38
+ f"[{detail}]"
39
+ )
@@ -0,0 +1,199 @@
1
+ """Derive the FOCUS 1.4 Invoice Detail dataset from Cost and Usage rows.
2
+
3
+ FOCUS 1.2/1.3 sources have no Invoice Detail dataset: it is new in 1.4. Each invoice line
4
+ item is derived by grouping the source Cost and Usage rows on the full **business grain** —
5
+ ``(InvoiceIssuerName, InvoiceId, BillingAccountId, BillingCurrency, BillingPeriodStart,
6
+ BillingPeriodEnd, ChargeCategory)`` — not on ``(InvoiceId, ChargeCategory)`` alone. The
7
+ richer key prevents merging genuinely-distinct lines when a source consolidates multiple
8
+ issuers, accounts, currencies or periods (rows without an ``InvoiceId`` are skipped).
9
+
10
+ * ``BilledCost`` is the exact Decimal sum of the group, so Invoice Detail reconciles with
11
+ Cost and Usage by construction.
12
+ * ``InvoiceDetailId`` is a **locally generated** id (``x_fdt_idl_v1_<hash>``): the prefix and
13
+ namespace mark it as toolkit-generated and the ``v1`` embeds the id-algorithm version, so it
14
+ is never mistaken for a real issuer-assigned id. The converted Cost and Usage rows carry the
15
+ same id, linking both datasets.
16
+ * Timestamps come from the billing period; ``InvoiceIssueStatus="Issued"`` /
17
+ ``PaymentTerms="Net 30"`` are deterministic documented defaults; ``ReferenceInvoiceId``
18
+ self-references (real correction linkage is unknown here).
19
+ * Conditional non-nullable columns with no source equivalent are omitted (the model allows it).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from collections.abc import Mapping, Sequence
27
+ from decimal import Decimal
28
+
29
+ from focus_data_toolkit.model import dataset_columns
30
+ from focus_data_toolkit.provenance import ColumnRule, Lineage
31
+
32
+ DATASET = "Invoice Detail"
33
+
34
+ # The business grain a synthetic invoice line is aggregated on, in key order.
35
+ GRAIN_FIELDS: tuple[str, ...] = (
36
+ "InvoiceIssuerName",
37
+ "InvoiceId",
38
+ "BillingAccountId",
39
+ "BillingCurrency",
40
+ "BillingPeriodStart",
41
+ "BillingPeriodEnd",
42
+ "ChargeCategory",
43
+ )
44
+ # Position of InvoiceId within a grain key (used to skip rows without one).
45
+ _INVOICE_ID_POS = GRAIN_FIELDS.index("InvoiceId")
46
+
47
+ # Locally-generated id namespace + algorithm version. Bump the version if the id derivation
48
+ # changes so ids from different algorithm versions never collide silently.
49
+ _LOCAL_ID_NAMESPACE = "x_fdt_idl"
50
+ _ID_ALGO_VERSION = "v1"
51
+
52
+ PROVENANCE: dict[str, ColumnRule] = {
53
+ "InvoiceDetailId": ColumnRule(
54
+ Lineage.ASSUMED, note="locally generated id (x_fdt_idl_v1_*); spec: issuer-assigned id"
55
+ ),
56
+ "InvoiceId": ColumnRule(Lineage.OBSERVED, "CostAndUsage.InvoiceId"),
57
+ "ReferenceInvoiceId": ColumnRule(
58
+ Lineage.ASSUMED, note="self-reference; real correction linkage unknown"
59
+ ),
60
+ "ChargeCategory": ColumnRule(Lineage.OBSERVED, "CostAndUsage.ChargeCategory"),
61
+ "BilledCost": ColumnRule(Lineage.DERIVED, "sum(CostAndUsage.BilledCost) over the business grain"),
62
+ "BillingAccountId": ColumnRule(Lineage.OBSERVED, "CostAndUsage.BillingAccountId"),
63
+ "BillingCurrency": ColumnRule(Lineage.OBSERVED, "CostAndUsage.BillingCurrency"),
64
+ "BillingPeriodStart": ColumnRule(Lineage.OBSERVED, "CostAndUsage.BillingPeriodStart"),
65
+ "BillingPeriodEnd": ColumnRule(Lineage.OBSERVED, "CostAndUsage.BillingPeriodEnd"),
66
+ "InvoiceDetailCreated": ColumnRule(Lineage.ASSUMED, note="provider record timestamp"),
67
+ "InvoiceDetailLastUpdated": ColumnRule(Lineage.ASSUMED, note="provider record timestamp"),
68
+ "InvoiceDetailDescription": ColumnRule(Lineage.ASSUMED, note="synthesized description"),
69
+ "InvoiceDetailGrain": ColumnRule(Lineage.ASSUMED, note="synthetic aggregation grain (x_ keys)"),
70
+ "InvoiceIssueDate": ColumnRule(Lineage.ASSUMED, note="provider invoice issue date"),
71
+ "InvoiceIssueStatus": ColumnRule(
72
+ Lineage.ASSUMED, note="provider publication state; assumed 'Issued'"
73
+ ),
74
+ "InvoiceIssuerName": ColumnRule(Lineage.OBSERVED, "CostAndUsage.InvoiceIssuerName"),
75
+ "PaymentDueDate": ColumnRule(Lineage.UNAVAILABLE, note="emitted null"),
76
+ "PaymentTerms": ColumnRule(Lineage.ASSUMED, note="assumed 'Net 30'"),
77
+ }
78
+
79
+ _OMITTED_CONDITIONAL = frozenset(
80
+ {
81
+ "PaymentCurrency",
82
+ "PaymentCurrencyBilledCost",
83
+ "PaymentCurrencyInvoiceDetailId",
84
+ "PurchaseOrderNumber",
85
+ }
86
+ )
87
+
88
+ # InvoiceDetailGrain is Key-Value Format; non-FOCUS-defined keys MUST be x_-prefixed
89
+ # (invoicedetailgrain.md @ v1.4). This grain is a synthetic aggregation descriptor.
90
+ _GRAIN = json.dumps(
91
+ {
92
+ "x_GroupedBy": ",".join(GRAIN_FIELDS),
93
+ "x_DerivedFrom": "FOCUS 1.x Cost and Usage aggregation",
94
+ "x_Generator": f"focus-data-toolkit {_LOCAL_ID_NAMESPACE}_{_ID_ALGO_VERSION}",
95
+ },
96
+ separators=(",", ":"),
97
+ )
98
+
99
+ _COST_QUANTUM = Decimal("0.000001")
100
+
101
+ GrainKey = tuple[str, ...]
102
+
103
+
104
+ def invoice_detail_grain_key(row: Mapping[str, str]) -> GrainKey:
105
+ """Return the (stripped) business-grain key of a Cost and Usage row."""
106
+ return tuple((row.get(field) or "").strip() for field in GRAIN_FIELDS)
107
+
108
+
109
+ def invoice_detail_id(grain_key: GrainKey) -> str:
110
+ """Deterministic, clearly-local InvoiceDetailId for one business-grain group.
111
+
112
+ Format ``x_fdt_idl_v1_<16 hex>``: the ``x_fdt_idl`` namespace and ``v1`` algorithm version
113
+ make it unmistakably toolkit-generated, and the hash covers the full grain so two distinct
114
+ (issuer, invoice, account, currency, period, category) lines never share an id.
115
+ """
116
+ # JSON-encode the key so a field containing the delimiter cannot forge a collision
117
+ # (e.g. ("A|B","C") vs ("A","B|C") hash differently).
118
+ payload = json.dumps(
119
+ [_LOCAL_ID_NAMESPACE, _ID_ALGO_VERSION, list(grain_key)], separators=(",", ":")
120
+ )
121
+ digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
122
+ return f"{_LOCAL_ID_NAMESPACE}_{_ID_ALGO_VERSION}_{digest}"
123
+
124
+
125
+ def invoice_detail_row(
126
+ grain_key: GrainKey,
127
+ billed_total: Decimal,
128
+ detail_id: str,
129
+ emitted: Sequence[str],
130
+ ) -> dict[str, str]:
131
+ """Build one Invoice Detail row from its grain key and pre-summed billed cost.
132
+
133
+ Every identity field comes from the grain key itself (not an arbitrary member row), so the
134
+ row is fully determined by ``(grain_key, billed_total)``. Shared by the eager and the
135
+ streaming pipelines (which sums in SQLite), which is what makes them provably equivalent.
136
+ """
137
+ issuer, invoice_id, account, currency, period_start, period_end, charge_category = grain_key
138
+ values = {
139
+ "InvoiceDetailId": detail_id,
140
+ "InvoiceId": invoice_id,
141
+ "ReferenceInvoiceId": invoice_id,
142
+ "ChargeCategory": charge_category,
143
+ "BilledCost": str(billed_total.quantize(_COST_QUANTUM)),
144
+ "BillingAccountId": account,
145
+ "BillingCurrency": currency,
146
+ "BillingPeriodStart": period_start,
147
+ "BillingPeriodEnd": period_end,
148
+ "InvoiceDetailCreated": period_end,
149
+ "InvoiceDetailLastUpdated": period_end,
150
+ "InvoiceDetailDescription": f"{charge_category} charges for invoice {invoice_id}",
151
+ "InvoiceDetailGrain": _GRAIN,
152
+ "InvoiceIssueDate": period_end,
153
+ "InvoiceIssueStatus": "Issued",
154
+ "InvoiceIssuerName": issuer,
155
+ "PaymentDueDate": "",
156
+ "PaymentTerms": "Net 30",
157
+ }
158
+ return {col: values.get(col, "") for col in emitted}
159
+
160
+
161
+ def invoice_detail_row_from_group(
162
+ grain_key: GrainKey,
163
+ members: Sequence[Mapping[str, str]],
164
+ detail_id: str,
165
+ emitted: Sequence[str],
166
+ ) -> dict[str, str]:
167
+ """Build one Invoice Detail row from an in-memory business-grain group (pure function)."""
168
+ billed = sum((Decimal(m.get("BilledCost") or "0") for m in members), Decimal(0))
169
+ return invoice_detail_row(grain_key, billed, detail_id, emitted)
170
+
171
+
172
+ def emitted_invoice_detail_columns() -> list[str]:
173
+ """The Invoice Detail columns actually emitted (omitting the unfilled conditional ones)."""
174
+ return [c for c in dataset_columns(DATASET) if c not in _OMITTED_CONDITIONAL]
175
+
176
+
177
+ def build_invoice_details(
178
+ cau_rows: list[dict[str, str]],
179
+ ) -> tuple[list[dict[str, str]], dict[GrainKey, str]]:
180
+ """Return ``(invoice_detail_rows, id_mapping)`` derived from ``cau_rows``.
181
+
182
+ ``id_mapping`` maps each business-grain key to its assigned ``InvoiceDetailId`` so the
183
+ Cost and Usage converter can back-link rows on exactly the same key.
184
+ """
185
+ emitted = [c for c in dataset_columns(DATASET) if c not in _OMITTED_CONDITIONAL]
186
+ groups: dict[GrainKey, list[dict[str, str]]] = {}
187
+ for row in cau_rows:
188
+ key = invoice_detail_grain_key(row)
189
+ if not key[_INVOICE_ID_POS]:
190
+ continue # a row with no InvoiceId cannot belong to an invoice line
191
+ groups.setdefault(key, []).append(row)
192
+
193
+ rows_out: list[dict[str, str]] = []
194
+ mapping: dict[GrainKey, str] = {}
195
+ for key, members in sorted(groups.items()):
196
+ detail_id = invoice_detail_id(key)
197
+ mapping[key] = detail_id
198
+ rows_out.append(invoice_detail_row_from_group(key, members, detail_id, emitted))
199
+ return rows_out, mapping