cimprofiles 0.1.0a2__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 (72) hide show
  1. cimprofiles/README.md +291 -0
  2. cimprofiles/__init__.py +36 -0
  3. cimprofiles/build_merged_profiles.py +261 -0
  4. cimprofiles/cim16/cim16v33/__init__.py +789 -0
  5. cimprofiles/cim16/cim16v33/cim16v33.py +39396 -0
  6. cimprofiles/cim16/cim16v33/cim16v33.rdfs-t2b.md +26221 -0
  7. cimprofiles/cim17/cgmes_3_0_0/__init__.py +2696 -0
  8. cimprofiles/cim17/cgmes_3_0_0/core_equipment/__init__.py +250 -0
  9. cimprofiles/cim17/cgmes_3_0_0/core_equipment/core_equipment.py +12615 -0
  10. cimprofiles/cim17/cgmes_3_0_0/diagram/__init__.py +29 -0
  11. cimprofiles/cim17/cgmes_3_0_0/diagram/diagram_layout.py +815 -0
  12. cimprofiles/cim17/cgmes_3_0_0/dynamics/__init__.py +9 -0
  13. cimprofiles/cim17/cgmes_3_0_0/dynamics/dynamics.py +18 -0
  14. cimprofiles/cim17/cgmes_3_0_0/equipment_boundary/__init__.py +34 -0
  15. cimprofiles/cim17/cgmes_3_0_0/equipment_boundary/equipment_boundary.py +1088 -0
  16. cimprofiles/cim17/cgmes_3_0_0/geographical_location/__init__.py +22 -0
  17. cimprofiles/cim17/cgmes_3_0_0/geographical_location/geographical_location.py +824 -0
  18. cimprofiles/cim17/cgmes_3_0_0/operation/__init__.py +67 -0
  19. cimprofiles/cim17/cgmes_3_0_0/operation/operation.py +2447 -0
  20. cimprofiles/cim17/cgmes_3_0_0/short_circuit/__init__.py +65 -0
  21. cimprofiles/cim17/cgmes_3_0_0/short_circuit/short_circuit.py +3769 -0
  22. cimprofiles/cim17/cgmes_3_0_0/state_variables/__init__.py +42 -0
  23. cimprofiles/cim17/cgmes_3_0_0/state_variables/state_variables.py +2286 -0
  24. cimprofiles/cim17/cgmes_3_0_0/steady_state_hypothesis/__init__.py +111 -0
  25. cimprofiles/cim17/cgmes_3_0_0/steady_state_hypothesis/steady_state_hypothesis.py +4112 -0
  26. cimprofiles/cim17/cgmes_3_0_0/topology/__init__.py +32 -0
  27. cimprofiles/cim17/cgmes_3_0_0/topology/topology.py +712 -0
  28. cimprofiles/cim17/cim17v40/__init__.py +1719 -0
  29. cimprofiles/cim17/cim17v40/cim17v40.py +177479 -0
  30. cimprofiles/cim17/cimhub_2023/__init__.py +621 -0
  31. cimprofiles/cim17/cimhub_2023/cimhub_2023.py +28616 -0
  32. cimprofiles/cim17/cimhub_2023/cimhub_2023.rdfs-t2b.md +18888 -0
  33. cimprofiles/cim17/gridappsd_rc4_2021/__init__.py +831 -0
  34. cimprofiles/cim17/gridappsd_rc4_2021/rc4_2021.py +49017 -0
  35. cimprofiles/cim18/cim18gad/__init__.py +2054 -0
  36. cimprofiles/cim18/cim18gad/cim18v15.py +198592 -0
  37. cimprofiles/cim18/cim18gmdm/__init__.py +1133 -0
  38. cimprofiles/cim18/cim18gmdm/asset/Asset.rdfs-t2b.md +781 -0
  39. cimprofiles/cim18/cim18gmdm/asset/__init__.py +41 -0
  40. cimprofiles/cim18/cim18gmdm/asset/asset.py +1427 -0
  41. cimprofiles/cim18/cim18gmdm/canonical/__init__.py +928 -0
  42. cimprofiles/cim18/cim18gmdm/canonical/canonical.py +65313 -0
  43. cimprofiles/cim18/cim18gmdm/connectivity/UnbalancedConnectivity.rdfs-t2b.md +2541 -0
  44. cimprofiles/cim18/cim18gmdm/connectivity/__init__.py +76 -0
  45. cimprofiles/cim18/cim18gmdm/connectivity/connectivity.py +2791 -0
  46. cimprofiles/cim18/cim18gmdm/diagram/__init__.py +26 -0
  47. cimprofiles/cim18/cim18gmdm/diagram/diagram.py +758 -0
  48. cimprofiles/cim18/cim18gmdm/electrical/UnbalancedElectrical.rdfs-t2b.md +3044 -0
  49. cimprofiles/cim18/cim18gmdm/electrical/__init__.py +140 -0
  50. cimprofiles/cim18/cim18gmdm/electrical/electrical.py +4107 -0
  51. cimprofiles/cim18/cim18gmdm/location/GeographicalLocation.rdfs-t2b.md +969 -0
  52. cimprofiles/cim18/cim18gmdm/location/__init__.py +69 -0
  53. cimprofiles/cim18/cim18gmdm/location/location.py +1103 -0
  54. cimprofiles/cim18/cim18gmdm/marketnode/__init__.py +15 -0
  55. cimprofiles/cim18/cim18gmdm/marketnode/marketnode.py +180 -0
  56. cimprofiles/cim18/cimhub_2026/__init__.py +550 -0
  57. cimprofiles/cim18/cimhub_2026/cimhub_2026.py +33382 -0
  58. cimprofiles/dedupe_profile_fields.py +150 -0
  59. cimprofiles/env_vars.py +48 -0
  60. cimprofiles/identity.py +453 -0
  61. cimprofiles/merge.py +472 -0
  62. cimprofiles/merge_releases.yaml +33 -0
  63. cimprofiles/units/__init__.py +2 -0
  64. cimprofiles/units/cim_units/__init__.py +2 -0
  65. cimprofiles/units/cim_units/unit_multiplier.py +113 -0
  66. cimprofiles/units/cim_units/unit_symbol.py +660 -0
  67. cimprofiles/units/cim_units/units.txt +95 -0
  68. cimprofiles/units/units.py +67 -0
  69. cimprofiles-0.1.0a2.dist-info/METADATA +128 -0
  70. cimprofiles-0.1.0a2.dist-info/RECORD +72 -0
  71. cimprofiles-0.1.0a2.dist-info/WHEEL +4 -0
  72. cimprofiles-0.1.0a2.dist-info/licenses/LICENSE +29 -0
cimprofiles/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # CIM Data Profiles
2
+
3
+ This package contains the Python data profile modules used by cim-graph
4
+ (and any other tool that wants the CIM classes without a database stack).
5
+ Each profile is a package of dataclasses generated from a CIM UML export
6
+ (via CIMTool or Enterprise Architect Schema Composer).
7
+
8
+ ## How profiles are selected
9
+
10
+ In cim-graph, set `CIMG_CIM_PROFILE` to the dotted module path of the profile you want:
11
+
12
+ ```bash
13
+ export CIMG_CIM_PROFILE="cimhub_2023"
14
+ # or fully-qualified:
15
+ export CIMG_CIM_PROFILE="cimprofiles.cim17.cimhub_2023"
16
+ ```
17
+
18
+ Profiles live under their CIM version (`cimprofiles.cim17.cimhub_2023`). A short name,
19
+ optionally with a sub-profile (`cim18gmdm.connectivity`), is looked up in
20
+ `cimprofiles.PROFILE_PACKAGES` by `cimprofiles.profile_module`. The pre-0.6
21
+ `cimgraph.data_profile.<name>` paths still work and resolve the same way.
22
+
23
+ ### Multi-profile merge — new in 0.5
24
+
25
+ Comma-separate two or more sub-profile paths to merge them at runtime:
26
+
27
+ ```bash
28
+ export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
29
+ cimprofiles.cim18.cim18gmdm.electrical,\
30
+ cimprofiles.cim18.cim18gmdm.asset"
31
+ ```
32
+
33
+ `get_cim_profile()` calls `merge_profiles()` once and memoises the result.
34
+ Every caller in the same process — `XMLFile`, `FeederModel`, any utility
35
+ function — receives the **same module object**, so class identity is
36
+ maintained across all graph key lookups.
37
+
38
+ The merged module satisfies the same contract as any single profile: it has
39
+ an `__all__` list and each class is accessible via `getattr(merged, name)`.
40
+
41
+ ---
42
+
43
+ ## Available profiles
44
+
45
+ ### Monolithic profiles
46
+
47
+ These are single-package profiles — set `CIMG_CIM_PROFILE` to the package
48
+ name and use the classes directly.
49
+
50
+ | Profile | Classes | Description |
51
+ |---|---|---|
52
+ | `cimhub_2023` | 397 | CIMHub 2023 distribution feeder profile (CIM100) |
53
+ | `cimhub_2026` | 449 | CIMHub 2026 distribution feeder profile (CIM100) |
54
+ | `cim16v33` | 533 | IEC CIM16 v33 |
55
+ | `cim17v40` | 1930 | IEC CIM17 v40 |
56
+ | `cim18gad` | 1348 | CIM18 GridAPPS-D profile |
57
+ | `rc4_2021` | 571 | RC4 2021 profile |
58
+
59
+ ### cim18gmdm — EPRI GMDM split profile
60
+
61
+ The GMDM model is split into sub-profiles by functional area. Use the
62
+ pre-baked merged package (`cim18gmdm`) for a single-import experience, or
63
+ compose the sub-profiles you need at runtime via the comma-spec.
64
+
65
+ #### Pre-baked merged package
66
+
67
+ ```bash
68
+ export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm"
69
+ ```
70
+
71
+ This is a committed `__init__.py` generated by `python -m cimprofiles.build_merged_profiles`.
72
+ It re-exports all classes from all five sub-profiles (978 classes total) and
73
+ is the equivalent of setting the comma-spec to all five sub-profiles.
74
+ Use it when you need every profile class available statically (e.g. for
75
+ Pylance completion on the full model).
76
+
77
+ #### Sub-profiles
78
+
79
+ Compose only the sub-profiles you need to keep the merged class count small
80
+ and avoid loading profile classes irrelevant to your task.
81
+
82
+ | Sub-profile | Classes | Key classes |
83
+ |---|---|---|
84
+ | `cim18gmdm.connectivity` | 56 | `ACLineSegment`, `ConnectivityNode`, `Terminal`, `Breaker`, `EnergyConsumer`, `PowerTransformer` |
85
+ | `cim18gmdm.electrical` | 77 | `PerLengthPhaseImpedance`, `PhaseImpedanceData`, `TransformerTankInfo`, `ACLineSegment.length`, `ACLineSegment.PerLengthImpedance` |
86
+ | `cim18gmdm.asset` | 24 | `Asset`, `AssetInfo`, `TransformerEndInfo`, `NoLoadTest`, `ShortCircuitTest` |
87
+ | `cim18gmdm.location` | 39 | `Location`, `PositionPoint`, `CoordinateSystem` |
88
+ | `cim18gmdm.marketnode` | 5 | `IndividualPnode`, `AggregateNode`, `Pnode` |
89
+ | `cim18gmdm.diagram` | 12 | `Diagram`, `DiagramObject`, `DiagramObjectPoint` |
90
+ | `cim18gmdm.canonical` | 765 | All classes in one file — legacy, prefer the pre-baked merged package |
91
+
92
+ **Common combinations:**
93
+
94
+ ```bash
95
+ # Topology + electrical parameters (most common for power flow):
96
+ export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
97
+ cimprofiles.cim18.cim18gmdm.electrical"
98
+
99
+ # Add asset catalog (transformer nameplate data, wire info):
100
+ export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
101
+ cimprofiles.cim18.cim18gmdm.electrical,\
102
+ cimprofiles.cim18.cim18gmdm.asset"
103
+
104
+ # Full five-part merge (equivalent to using the pre-baked cim18gmdm package):
105
+ export CIMG_CIM_PROFILE="cimprofiles.cim18.cim18gmdm.connectivity,\
106
+ cimprofiles.cim18.cim18gmdm.electrical,\
107
+ cimprofiles.cim18.cim18gmdm.asset,\
108
+ cimprofiles.cim18.cim18gmdm.location,\
109
+ cimprofiles.cim18.cim18gmdm.marketnode"
110
+ ```
111
+
112
+ ### cgmes_3_0_0 — IEC CGMES 3.0 split profile
113
+
114
+ CGMES 3.0 is split into sub-profiles by exchange type, following the IEC
115
+ 61970-600 document structure.
116
+
117
+ | Sub-profile | Classes | Description |
118
+ |---|---|---|
119
+ | `cgmes_3_0_0.core_equipment` | 203 | EQ — equipment definitions |
120
+ | `cgmes_3_0_0.equipment_boundary` | 22 | EQBD — boundary equipment |
121
+ | `cgmes_3_0_0.operation` | 39 | OP — operational limits |
122
+ | `cgmes_3_0_0.short_circuit` | 48 | SC — short-circuit parameters |
123
+ | `cgmes_3_0_0.steady_state_hypothesis` | 85 | SSH — schedule/setpoints |
124
+ | `cgmes_3_0_0.state_variables` | 29 | SV — solved state |
125
+ | `cgmes_3_0_0.topology` | 15 | TP — bus-branch topology |
126
+ | `cgmes_3_0_0.geographical_location` | 12 | GL — geographic coordinates |
127
+ | `cgmes_3_0_0.diagram` | 12 | DL — diagram layout |
128
+
129
+ **Typical CGMES merge (EQ + SSH + SV):**
130
+
131
+ ```bash
132
+ export CIMG_CIM_PROFILE="cimprofiles.cim17.cgmes_3_0_0.core_equipment,\
133
+ cimprofiles.cim17.cgmes_3_0_0.steady_state_hypothesis,\
134
+ cimprofiles.cim17.cgmes_3_0_0.state_variables"
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Loading split exports
140
+
141
+ For models spread across multiple part files, use an IEC 61970-552 metadata
142
+ header (`.cimx` file) to load all parts in one call:
143
+
144
+ ```python
145
+ import os
146
+ from cimgraph.databases import XMLFile
147
+ from cimgraph.models import FeederModel
148
+
149
+ os.environ['CIMG_CIM_PROFILE'] = (
150
+ 'cimprofiles.cim18.cim18gmdm.connectivity,'
151
+ 'cimprofiles.cim18.cim18gmdm.electrical,'
152
+ 'cimprofiles.cim18.cim18gmdm.asset'
153
+ )
154
+
155
+ # 9500node.cimx lists the individual part files as dcat:Distribution entries.
156
+ # XMLFile resolves each accessURL relative to the .cimx file and loads all
157
+ # parts into one graph in a single two-pass read.
158
+ file = XMLFile(metadata='profile_parts/9500node.cimx')
159
+ network = FeederModel(container=None, connection=file)
160
+ ```
161
+
162
+ The `.cimx` format follows IEC 61970-552 §8. Each part file is a
163
+ `dcat:Distribution` with an `accessURL` (relative path) and a
164
+ `dcterms:conformsTo` URI naming the sub-profile it was written against.
165
+
166
+ ---
167
+
168
+ ## Adding a custom profile
169
+
170
+ 1. Export your UML profile from CIMTool or Schema Composer as `.xsd`.
171
+ 2. Run `uv run xsdata generate` (or the equivalent CIMTool builder) to
172
+ produce the dataclass package.
173
+ 3. Place the generated package under `src/cimprofiles/` (or any importable package).
174
+ 4. Set `CIMG_CIM_PROFILE` to the dotted path of the package.
175
+
176
+ For split profiles, generate one sub-package per functional area and use
177
+ the comma-spec to merge them at runtime.
178
+
179
+ ---
180
+
181
+ ## Generating a pre-baked `__init__.py` for a split profile
182
+
183
+ A pre-baked `__init__.py` lets users `import cimprofiles.<name> as cim`
184
+ without knowing the sub-profile names, and lets Pylance/mypy resolve the full
185
+ merged class surface statically.
186
+
187
+ ### 1. Register the release in `merge_releases.yaml`
188
+
189
+ Add an entry to `src/cimprofiles/merge_releases.yaml`:
190
+
191
+ ```yaml
192
+ releases:
193
+ - name: my_profile # must match the package directory name
194
+ package: cimprofiles.my_profile
195
+ parts:
196
+ - cimprofiles.my_profile.connectivity
197
+ - cimprofiles.my_profile.electrical
198
+ - cimprofiles.my_profile.asset
199
+ description: "My profile — merged from connectivity + electrical + asset"
200
+ ```
201
+
202
+ `parts` is an ordered list. First-seen order wins for classes that appear in
203
+ multiple sub-profiles, so put the most complete sub-profile first.
204
+
205
+ **Prerequisite — base class consistency:** `merge_profiles` fails fast if two
206
+ sub-profiles declare the same class with *different* direct base classes. This
207
+ happens when a partial sub-profile uses `Identity` as a stub base for a class
208
+ that a fuller sub-profile roots at `IdentifiedObject` or deeper. Fix by
209
+ regenerating the offending sub-profile from CIMTool with the full inheritance
210
+ chain before running the build script.
211
+
212
+ To diagnose conflicts before registering:
213
+
214
+ ```python
215
+ import importlib
216
+ from collections import defaultdict
217
+ from cimprofiles.merge import merge_profiles
218
+
219
+ parts = ['cimprofiles.my_profile.connectivity',
220
+ 'cimprofiles.my_profile.electrical']
221
+ mods = [importlib.import_module(p) for p in parts]
222
+
223
+ class_defs = defaultdict(list)
224
+ for mod in mods:
225
+ for name in getattr(mod, '__all__', []):
226
+ obj = getattr(mod, name, None)
227
+ if obj and isinstance(obj, type):
228
+ class_defs[name].append(obj)
229
+
230
+ for name, defs in sorted(class_defs.items()):
231
+ bases = set()
232
+ for cls in defs:
233
+ for base in cls.__mro__[1:]:
234
+ if base is not object:
235
+ bases.add(base.__name__)
236
+ break
237
+ if len(bases) > 1:
238
+ print(f'{name}: conflicting bases {sorted(bases)}')
239
+ ```
240
+
241
+ ### 2. Run the build script
242
+
243
+ ```bash
244
+ uv run python -m cimprofiles.build_merged_profiles
245
+ ```
246
+
247
+ This writes a committed `__init__.py` into each package listed in
248
+ `merge_releases.yaml` and smoke-tests the import. Re-run after adding or
249
+ changing sub-profile parts.
250
+
251
+ The generated file is a normal Python module — Pylance reads it directly.
252
+ It re-exports every class from the appropriate sub-profile, so the source
253
+ of each class is traceable and static analysis works without the runtime
254
+ merge machinery.
255
+
256
+ ### Current status
257
+
258
+ | Profile | `__init__.py` | Notes |
259
+ |---|---|---|
260
+ | `cim18gmdm` | ✅ generated | 116 classes from 5 sub-profiles |
261
+ | `cgmes_3_0_0` | ✅ generated | 275 classes from 9 sub-profiles (EQ+EQBD+OP+SC+SSH+SV+TP+GL+DL) |
262
+
263
+ ---
264
+
265
+ ## Runtime merge internals
266
+
267
+ `cimprofiles.merge.merge_profiles(*modules)` builds a synthetic
268
+ `types.ModuleType` by:
269
+
270
+ 1. Collecting every name from each module's `__all__`.
271
+ 2. Topologically sorting by inheritance (parents before children).
272
+ 3. For classes that appear in more than one sub-profile: creating a new
273
+ dataclass with the **union of direct fields**, reparented onto the
274
+ already-merged base class.
275
+ 4. For classes that appear in only one sub-profile: adopting the class
276
+ as-is but reparenting it onto the merged base if the base was rebuilt.
277
+ 5. Enums, CIMUnit subclasses, and the shared `Identity` root are taken from
278
+ the first profile that defines them.
279
+
280
+ The merge fails fast if two sub-profiles declare the same class name with
281
+ **different base classes** — that would silently drop a lineage.
282
+
283
+ To generate a static type-stub file for Pylance/mypy (the replacement for
284
+ the hand-maintained `canonical.py`):
285
+
286
+ ```python
287
+ from cimprofiles.cim18.cim18gmdm import connectivity, electrical
288
+ from cimprofiles.merge import generate_type_stubs
289
+
290
+ generate_type_stubs(connectivity, electrical, output_path='cim_merged.pyi')
291
+ ```
@@ -0,0 +1,36 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CIM_PROFILE(Enum):
5
+ RC4_2021 = 'rc4_2021'
6
+ CIMHUB_2023 = 'cimhub_2023'
7
+ CIMHUB_2026 = 'cimhub_2026'
8
+ CIM16V33 = 'cim16v33'
9
+ CIM17V40 = 'cim17v40'
10
+ CIM18GMDM = 'cim18gmdm'
11
+
12
+
13
+ # Short profile names, as used by CIMG_CIM_PROFILE and cimgraph.data_profile -> package
14
+ PROFILE_PACKAGES = {
15
+ 'cim16v33': 'cimprofiles.cim16.cim16v33',
16
+ 'cgmes_3_0_0': 'cimprofiles.cim17.cgmes_3_0_0',
17
+ 'cim17v40': 'cimprofiles.cim17.cim17v40',
18
+ 'cimhub_2023': 'cimprofiles.cim17.cimhub_2023',
19
+ 'rc4_2021': 'cimprofiles.cim17.gridappsd_rc4_2021',
20
+ 'cim18gad': 'cimprofiles.cim18.cim18gad',
21
+ 'cim18gmdm': 'cimprofiles.cim18.cim18gmdm',
22
+ 'cimhub_2026': 'cimprofiles.cim18.cimhub_2026',
23
+ }
24
+
25
+
26
+ def profile_module(name: str) -> str:
27
+ """
28
+ Returns the module path of a profile given by short name, optionally with a
29
+ sub-profile: 'cimhub_2023' -> 'cimprofiles.cim17.cimhub_2023',
30
+ 'cim18gmdm.connectivity' -> 'cimprofiles.cim18.cim18gmdm.connectivity'.
31
+ """
32
+ short_name, dot, sub_profile = name.partition('.')
33
+ if short_name not in PROFILE_PACKAGES:
34
+ raise ValueError(f'Unknown CIM profile {name!r}; known profiles: '
35
+ f'{", ".join(PROFILE_PACKAGES)}')
36
+ return PROFILE_PACKAGES[short_name] + dot + sub_profile
@@ -0,0 +1,261 @@
1
+ """Build pre-baked merged profile __init__.py files from merge_releases.yaml.
2
+
3
+ Run with the development dependencies installed (needs pyyaml):
4
+ uv run python -m cimprofiles.build_merged_profiles
5
+ uv run python -m cimprofiles.build_merged_profiles --config path/to/releases.yaml
6
+
7
+ ``--config`` builds releases for a profile package outside this repo (e.g.
8
+ cim-vfm-profiles). Each release's ``package`` must be importable; its
9
+ ``__init__.py`` is written wherever that package lives.
10
+
11
+ Each entry in merge_releases.yaml produces a committed __init__.py that
12
+ re-exports the merged class surface. The file is a normal Python module —
13
+ Pylance reads it directly, and `import cimprofiles.<name> as cim`
14
+ resolves to the merged class set with no runtime merge machinery needed.
15
+
16
+ For classes that appear in only one sub-profile, the output is a simple
17
+ re-import. For classes that overlap across sub-profiles (same name, different
18
+ fields in each), the output is an inline @dataclass definition that carries the
19
+ union of all direct fields — this is the case for CGMES where e.g. ACLineSegment
20
+ appears in both EQ (r, x, bch) and SC (r0, x0, b0ch) sub-profiles.
21
+
22
+ Add this to the PyPI release process for any profile listed in the config.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import dataclasses
28
+ import importlib
29
+ import importlib.util
30
+ import sys
31
+ import textwrap
32
+ from collections import defaultdict
33
+ from pathlib import Path
34
+
35
+ import yaml
36
+
37
+ _CONFIG = Path(__file__).parent / 'merge_releases.yaml'
38
+
39
+
40
+ def _load_config(config: Path) -> list[dict]:
41
+ with open(config) as f:
42
+ data = yaml.safe_load(f)
43
+ return data['releases']
44
+
45
+
46
+ def _package_dir(package: str) -> Path:
47
+ """Where *package* lives — resolved by import, so external packages work too."""
48
+ spec = importlib.util.find_spec(package)
49
+ if spec is None or not spec.submodule_search_locations:
50
+ raise FileNotFoundError(
51
+ f'Package not importable: {package}\n'
52
+ f'Create it and add sub-profile packages before running this script.'
53
+ )
54
+ return Path(next(iter(spec.submodule_search_locations)))
55
+
56
+
57
+ def _sanitize_metadata(meta: dict) -> dict:
58
+ """Strip newlines from string metadata values so repr() is safe in source."""
59
+ return {
60
+ k: v.replace('\n', ' ').strip() if isinstance(v, str) else v
61
+ for k, v in meta.items()
62
+ }
63
+
64
+
65
+ def _field_source(f: dataclasses.Field) -> str:
66
+ """Emit a field(...) expression that recreates the field descriptor."""
67
+ parts: list[str] = []
68
+ if f.default is not dataclasses.MISSING:
69
+ parts.append(f'default={f.default!r}')
70
+ elif f.default_factory is not dataclasses.MISSING:
71
+ factory = f.default_factory
72
+ if factory is list:
73
+ parts.append('default_factory=list')
74
+ elif factory is dict:
75
+ parts.append('default_factory=dict')
76
+ else:
77
+ parts.append(f'default_factory={factory.__name__}')
78
+ meta = _sanitize_metadata(dict(f.metadata))
79
+ parts.append(f'metadata={meta!r}')
80
+ return f'field({", ".join(parts)})'
81
+
82
+
83
+ def _emit_inline_class(name: str, merged_cls: type, merged_base_name: str) -> list[str]:
84
+ """Emit an inline @dataclass definition for an overlapping class."""
85
+ from cimprofiles.merge import _get_direct_fields
86
+ direct = _get_direct_fields(merged_cls)
87
+ lines: list[str] = []
88
+ lines.append('@dataclass(repr=False, eq=False)')
89
+ lines.append(f'class {name}({merged_base_name}):')
90
+
91
+ # Preserve the original docstring — make_dataclass overwrites __doc__ with
92
+ # the signature string, so we must emit it explicitly.
93
+ doc = merged_cls.__doc__
94
+ if doc and not doc.startswith(f'{name}('):
95
+ # Indent each line of the docstring body by 4 spaces.
96
+ indented = '\n'.join(' ' + l for l in doc.split('\n'))
97
+ lines.append(f' """{indented.strip()}\n """')
98
+
99
+ if not direct:
100
+ lines.append(' pass')
101
+ else:
102
+ for fname, f in direct.items():
103
+ ftype = f.type if isinstance(f.type, str) else str(f.type)
104
+ field_expr = _field_source(f)
105
+ # Never wrap — the metadata repr contains string literals that
106
+ # textwrap would break mid-literal, producing a SyntaxError.
107
+ lines.append(f' {fname}: {ftype} = {field_expr}')
108
+ return lines
109
+
110
+
111
+ def _build_init(release: dict) -> str:
112
+ """Return the generated __init__.py content for one release entry."""
113
+ from cimprofiles.identity import Identity
114
+ from cimprofiles.merge import _get_direct_fields, merge_profiles
115
+
116
+ parts = release['parts']
117
+ description = release.get('description', '')
118
+ package = release['package']
119
+
120
+ modules = [importlib.import_module(p) for p in parts]
121
+ merged = merge_profiles(*modules)
122
+
123
+ names = sorted(merged.__all__)
124
+
125
+ # ── Determine which classes are overlapping vs single-source ──────────
126
+ # A class is "overlapping" if the same name with *different field sets*
127
+ # appears in more than one sub-profile. For those we emit an inline
128
+ # @dataclass. For single-source (or Identity/enum/cimunit), we re-import.
129
+ name_to_parts: dict[str, list[str]] = defaultdict(list)
130
+ name_to_first_part: dict[str, str] = {}
131
+ for part_path, mod in zip(parts, modules):
132
+ for n in getattr(mod, '__all__', []):
133
+ if n not in name_to_first_part:
134
+ name_to_first_part[n] = part_path
135
+ name_to_parts[n].append(part_path)
136
+
137
+ # Collect the merged module's class objects
138
+ merged_classes: dict[str, type] = {}
139
+ for n in names:
140
+ obj = getattr(merged, n, None)
141
+ if obj is not None and isinstance(obj, type):
142
+ merged_classes[n] = obj
143
+
144
+ # An overlapping dataclass is one where the merged object is NOT the same
145
+ # object as in the first-seen sub-profile (merge_profiles rebuilt it).
146
+ from cimprofiles.merge import _is_profile_dataclass
147
+ overlapping: set[str] = set()
148
+ for n, cls in merged_classes.items():
149
+ if not _is_profile_dataclass(cls) or n == 'Identity':
150
+ continue
151
+ first_mod = importlib.import_module(name_to_first_part[n])
152
+ first_cls = getattr(first_mod, n, None)
153
+ if first_cls is not None and cls is not first_cls:
154
+ overlapping.add(n)
155
+
156
+ # ── Topological order for the inline dataclass section ─────────────────
157
+ # Only overlapping dataclasses need topo sorting (parents before children).
158
+ from cimprofiles.merge import _topo_sort
159
+ class_defs_for_sort: dict[str, list[type]] = {
160
+ n: [merged_classes[n]] for n in overlapping if n in merged_classes
161
+ }
162
+ topo_overlapping = _topo_sort(list(overlapping), class_defs_for_sort)
163
+
164
+ # ── Imports for non-overlapping names ──────────────────────────────────
165
+ part_to_simple: dict[str, list[str]] = defaultdict(list)
166
+ for n in sorted(names):
167
+ if n not in overlapping:
168
+ src = name_to_first_part.get(n, parts[0])
169
+ part_to_simple[src].append(n)
170
+
171
+ import_lines: list[str] = []
172
+ for part_path in parts:
173
+ part_names = part_to_simple.get(part_path, [])
174
+ if not part_names:
175
+ continue
176
+ header = f'from {part_path} import ('
177
+ joined = ', '.join(part_names)
178
+ wrapped_names = textwrap.fill(
179
+ joined,
180
+ width=88,
181
+ initial_indent=' ',
182
+ subsequent_indent=' ',
183
+ break_long_words=False,
184
+ break_on_hyphens=False,
185
+ )
186
+ import_lines.append(f'{header}\n{wrapped_names})')
187
+
188
+ # ── Inline @dataclass section ──────────────────────────────────────────
189
+ # We need 'dataclass' and 'field' in scope for the generated code.
190
+ inline_lines: list[str] = []
191
+ for n in topo_overlapping:
192
+ cls = merged_classes[n]
193
+ # Base: the first real base in the merged class's MRO
194
+ base = cls.__bases__[0]
195
+ base_name = base.__name__
196
+ inline_lines.append('')
197
+ inline_lines.extend(_emit_inline_class(n, cls, base_name))
198
+
199
+ # ── __all__ ────────────────────────────────────────────────────────────
200
+ all_entries = '\n'.join(f" '{n}'," for n in names)
201
+ parts_comment = '\n'.join(f'# {p}' for p in parts)
202
+
203
+ has_inline = bool(inline_lines)
204
+ # Inline @dataclass fields use 'Optional[...]' and 'UUID' in their type
205
+ # annotations. Without these imports Pylance resolves them as Any.
206
+ inline_imports = (
207
+ 'from dataclasses import dataclass, field\n'
208
+ 'from typing import Optional\n'
209
+ 'from uuid import UUID\n'
210
+ if has_inline else ''
211
+ )
212
+
213
+ return f'''\
214
+ """Merged profile: {package}
215
+
216
+ {description}
217
+
218
+ Parts:
219
+ {parts_comment}
220
+
221
+ Generated by scripts/build_merged_profiles.py — do not edit by hand.
222
+ Re-run the script after adding or updating sub-profile parts.
223
+ """
224
+ from __future__ import annotations
225
+
226
+ {inline_imports}{chr(10).join(import_lines)}
227
+ {''.join(line + chr(10) for line in inline_lines)}
228
+ __all__ = [
229
+ {all_entries}
230
+ ]
231
+ '''
232
+
233
+
234
+ def main() -> None:
235
+ parser = argparse.ArgumentParser(description='Build pre-baked merged profile packages.')
236
+ parser.add_argument('--config', type=Path, default=_CONFIG,
237
+ help=f'releases yaml (default: {_CONFIG.name})')
238
+ args = parser.parse_args()
239
+
240
+ releases = _load_config(args.config)
241
+ for release in releases:
242
+ package = release['package']
243
+
244
+ print(f'Building {package} ...')
245
+ content = _build_init(release)
246
+
247
+ # Write to the package __init__.py.
248
+ init_path = _package_dir(package) / '__init__.py'
249
+ init_path.write_text(content, encoding='utf-8')
250
+ print(f' wrote {init_path}')
251
+
252
+ # Smoke-test: the written file must be importable and expose __all__.
253
+ if package in sys.modules:
254
+ del sys.modules[package]
255
+ mod = importlib.import_module(package)
256
+ assert hasattr(mod, '__all__'), f'{package}.__all__ missing after write'
257
+ print(f' ok — {len(mod.__all__)} classes exported')
258
+
259
+
260
+ if __name__ == '__main__':
261
+ main()