leancpms-cli 0.1.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.
File without changes
@@ -0,0 +1,118 @@
1
+ """Reading, writing and diffing manifest documents. No framework, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import yaml
11
+ from pydantic import TypeAdapter, ValidationError
12
+
13
+ from .schema import MANIFEST_VERSION, Document
14
+
15
+ _adapter: TypeAdapter[Document] = TypeAdapter(Document)
16
+
17
+
18
+ class ManifestError(ValueError):
19
+ """A document that does not validate. `errors` is a list of readable lines."""
20
+
21
+ def __init__(self, errors: list[str]):
22
+ super().__init__("; ".join(errors))
23
+ self.errors = errors
24
+
25
+
26
+ def parse(data: Any) -> Document:
27
+ try:
28
+ return _adapter.validate_python(data)
29
+ except ValidationError as e:
30
+ raise ManifestError(
31
+ [f"{'.'.join(str(p) for p in err['loc']) or '(document)'}: {err['msg']}" for err in e.errors()]
32
+ ) from None
33
+
34
+
35
+ def parse_yaml(text: str) -> list[Document]:
36
+ """One or more documents separated by `---`."""
37
+ try:
38
+ raw = [d for d in yaml.safe_load_all(text) if d is not None]
39
+ except yaml.YAMLError as e:
40
+ raise ManifestError([f"YAML: {e}"]) from None
41
+ return [parse(d) for d in raw]
42
+
43
+
44
+ def json_schema() -> dict[str, Any]:
45
+ """The JSON Schema of a document of any kind, for editors (`cpms manifest schema`).
46
+
47
+ It says less than `parse`: the checks across fields (budget within the
48
+ fuse, unique keys, references between plans and tariffs, valid time
49
+ zones) are only in the validators, so `validate` and `plan` still decide.
50
+ """
51
+ return {"$schema": "https://json-schema.org/draft/2020-12/schema",
52
+ "title": f"LeanCPMS manifest document ({MANIFEST_VERSION})",
53
+ **_adapter.json_schema()}
54
+
55
+
56
+ def to_data(doc: Document) -> dict[str, Any]:
57
+ """The stored form: JSON-safe, money as strings, defaults left out."""
58
+ return doc.model_dump(mode="json", by_alias=True, exclude_defaults=True)
59
+
60
+
61
+ def to_yaml(docs: list[dict[str, Any]]) -> str:
62
+ return "---\n".join(yaml.safe_dump(d, sort_keys=False, allow_unicode=True) for d in docs)
63
+
64
+
65
+ def digest(data: dict[str, Any] | None) -> str:
66
+ """Stable hash of a stored document; `apply` uses it for optimistic concurrency."""
67
+ if data is None:
68
+ return "none"
69
+ return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Change:
74
+ op: str # "+", "-", "~"
75
+ path: str
76
+ old: Any = None
77
+ new: Any = None
78
+
79
+ def __str__(self) -> str:
80
+ if self.op == "~":
81
+ return f"~ {self.path}: {_short(self.old)} → {_short(self.new)}"
82
+ return f"{self.op} {self.path}"
83
+
84
+
85
+ def _short(v: Any) -> str:
86
+ s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v
87
+ return s if len(s) <= 60 else s[:57] + "..."
88
+
89
+
90
+ def _keyed(items: list[Any]) -> bool:
91
+ return bool(items) and all(isinstance(i, dict) and "key" in i for i in items)
92
+
93
+
94
+ def diff(old: Any, new: Any, path: str = "") -> list[Change]:
95
+ """Changes from `old` to `new`; lists of objects with `key` are matched by key."""
96
+ if isinstance(old, dict) and isinstance(new, dict):
97
+ out: list[Change] = []
98
+ for k in list(old) + [k for k in new if k not in old]:
99
+ p = f"{path}.{k}" if path else k
100
+ if k not in new:
101
+ out.append(Change("-", p, old[k], None))
102
+ elif k not in old:
103
+ out.append(Change("+", p, None, new[k]))
104
+ else:
105
+ out.extend(diff(old[k], new[k], p))
106
+ return out
107
+ if isinstance(old, list) and isinstance(new, list) and (_keyed(old) or _keyed(new)) and (
108
+ all(isinstance(i, dict) and "key" in i for i in old + new)
109
+ ):
110
+ o = {i["key"]: i for i in old}
111
+ n = {i["key"]: i for i in new}
112
+ out = []
113
+ for k in o:
114
+ p = f"{path}[{k}]"
115
+ out.extend([Change("-", p, o[k], None)] if k not in n else diff(o[k], n[k], p))
116
+ out.extend(Change("+", f"{path}[{k}]", None, n[k]) for k in n if k not in o)
117
+ return out
118
+ return [] if old == new else [Change("~", path, old, new)]
@@ -0,0 +1,374 @@
1
+ """The manifest: three kinds of document per organization (architecture §6.1).
2
+
3
+ Plain Pydantic, no framework. The same models are the JSON Schema of the
4
+ files, the payload of the API, and the balancer's input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ from decimal import Decimal
11
+ from typing import Annotated, Literal, Sequence, Union
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
13
+
14
+ from pydantic import (
15
+ BaseModel,
16
+ AfterValidator,
17
+ BeforeValidator,
18
+ ConfigDict,
19
+ Field,
20
+ StringConstraints,
21
+ WithJsonSchema,
22
+ model_validator,
23
+ )
24
+
25
+ MANIFEST_VERSION = "cpms/v1"
26
+
27
+ Key = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9_-]{0,62}$")]
28
+ Phase = Literal["L1", "L2", "L3"]
29
+
30
+
31
+ def _no_float(value: object) -> object:
32
+ # YAML turns 4.50 into a float, and a float is not money. Quote it.
33
+ if isinstance(value, float):
34
+ raise ValueError("amounts must be quoted strings, e.g. \"4.50\", not floats")
35
+ return value
36
+
37
+
38
+ # What the JSON Schema says (an editor's view): an integer or a string, never a float.
39
+ _DECIMAL_TEXT = WithJsonSchema({"anyOf": [{"type": "integer"}, {"type": "string"}],
40
+ "description": 'quoted, e.g. "4.50"; a float is not money'})
41
+ Money = Annotated[Decimal, BeforeValidator(_no_float), _DECIMAL_TEXT]
42
+
43
+
44
+ def _quoted(value: object) -> object:
45
+ # A coordinate as a YAML float loses its trailing zeros; OCPI sends it as a string.
46
+ if isinstance(value, (float, int)) and not isinstance(value, bool):
47
+ raise ValueError("coordinates must be quoted strings, e.g. \"59.3600000\"")
48
+ return value
49
+
50
+
51
+ def _zone(value: str) -> str:
52
+ try:
53
+ ZoneInfo(value)
54
+ except (ZoneInfoNotFoundError, ValueError):
55
+ raise ValueError(f"unknown time zone {value!r} (an IANA name such as Europe/Stockholm)") from None
56
+ return value
57
+
58
+
59
+ TimeZone = Annotated[str, AfterValidator(_zone)]
60
+
61
+ # The portal's own pages under /portal/ (cpms/portal/urls.py): an organization keyed so would be hidden
62
+ # behind them, since /portal/<organization>/ is matched after them. A test keeps the list complete.
63
+ RESERVED_ORGANIZATION_KEYS = frozenset({"cli", "login", "logout", "inactive"})
64
+
65
+
66
+ def _not_reserved(value: str) -> str:
67
+ if value in RESERVED_ORGANIZATION_KEYS:
68
+ raise ValueError(f"{value!r} is reserved (a portal page); choose another organization key")
69
+ return value
70
+
71
+
72
+ OrganizationKey = Annotated[Key, AfterValidator(_not_reserved)]
73
+ Rate = Annotated[Decimal, BeforeValidator(_no_float), Field(ge=0, le=1), _DECIMAL_TEXT]
74
+
75
+
76
+ class Strict(BaseModel):
77
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
78
+
79
+
80
+ def _unique(items: Sequence[object], attr: str, what: str) -> None:
81
+ seen: set[object] = set()
82
+ for item in items:
83
+ value = getattr(item, attr)
84
+ if value in seen:
85
+ raise ValueError(f"duplicate {what} {value!r}")
86
+ seen.add(value)
87
+
88
+
89
+ # --- organization -----------------------------------------------------------
90
+
91
+
92
+ class Theme(Strict):
93
+ """How the organization's driver pages look (architecture §14). Optional; the default is neutral."""
94
+
95
+ primary: Annotated[str, StringConstraints(pattern=r"^#[0-9a-fA-F]{6}$")] | None = None
96
+ logo_url: Annotated[str, StringConstraints(pattern=r"^https://\S+$", max_length=500)] | None = None
97
+
98
+
99
+ class Organization(Strict):
100
+ key: OrganizationKey
101
+ name: str
102
+ org_number: str | None = None
103
+ vat_number: str | None = None
104
+ address: str | None = None
105
+ email: str | None = None
106
+ payment_mode: Literal["direct", "platform"] = "direct"
107
+ currency: Literal["SEK", "EUR", "NOK", "DKK"] = "SEK"
108
+ display_timezone: str = "Europe/Stockholm"
109
+ # The eMI3 operator id, e.g. "SE*KEL": the OCPI party (country code, party id) and the EVSE ids' prefix.
110
+ operator_id: Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}\*[A-Z0-9]{3}$")] | None = None
111
+ theme: Theme | None = None
112
+
113
+
114
+ class OrganizationDocument(Strict):
115
+ manifest: Literal["cpms/v1"]
116
+ kind: Literal["organization"]
117
+ organization: Organization
118
+
119
+ @property
120
+ def org_key(self) -> str:
121
+ return self.organization.key
122
+
123
+ @property
124
+ def doc_key(self) -> str:
125
+ return self.organization.key
126
+
127
+
128
+ # --- price list -------------------------------------------------------------
129
+
130
+
131
+ class FlatPrice(Strict):
132
+ from_: dt.date = Field(alias="from")
133
+ per_kwh: Money = Field(ge=0)
134
+
135
+
136
+ class SpotPrice(Strict):
137
+ from_: dt.date = Field(alias="from")
138
+ spot_markup_per_kwh: Money = Field(ge=0)
139
+ fallback_per_kwh: Money | None = Field(default=None, ge=0)
140
+
141
+
142
+ class _TariffBase(Strict):
143
+ key: Key
144
+ name: str | None = None
145
+ amounts: Literal["incl_vat", "excl_vat"] # required: which side of the price is exact
146
+ vat_rate: Rate
147
+
148
+ @model_validator(mode="after")
149
+ def _dated_rows(self): # type: ignore[no-untyped-def]
150
+ prices = getattr(self, "prices")
151
+ if not prices:
152
+ raise ValueError(f"tariff {self.key!r} has no price rows")
153
+ dates = [p.from_ for p in prices]
154
+ if dates != sorted(dates) or len(set(dates)) != len(dates):
155
+ raise ValueError(f"tariff {self.key!r}: price rows must be in strictly increasing date order")
156
+ return self
157
+
158
+
159
+ class FlatTariff(_TariffBase):
160
+ kind: Literal["flat"]
161
+ prices: list[FlatPrice]
162
+
163
+
164
+ class SpotTariff(_TariffBase):
165
+ kind: Literal["spot"]
166
+ spot_area: Literal["SE1", "SE2", "SE3", "SE4"]
167
+ prices: list[SpotPrice]
168
+
169
+
170
+ Tariff = Annotated[Union[FlatTariff, SpotTariff], Field(discriminator="kind")]
171
+
172
+
173
+ class Fee(Strict):
174
+ from_: dt.date = Field(alias="from")
175
+ monthly: Money = Field(ge=0)
176
+
177
+
178
+ class Billing(Strict):
179
+ period: Literal["month"] = "month"
180
+ fee: Literal["in_advance"] = "in_advance"
181
+ energy: Literal["in_arrears"] = "in_arrears"
182
+ on_end: Literal["public"] = "public"
183
+
184
+
185
+ class Plan(Strict):
186
+ key: Key
187
+ name: str | None = None
188
+ tariff: Key
189
+ requires: Literal["none", "subscription", "roaming"]
190
+ preauth_amount: Money | None = Field(default=None, gt=0) # incl. VAT, reserved on the card
191
+ fees: list[Fee] = []
192
+ billing: Billing | None = None
193
+
194
+
195
+ class RoamingChannel(Strict):
196
+ default: Key
197
+
198
+
199
+ class Channels(Strict):
200
+ adhoc: list[Key] = []
201
+ roaming: RoamingChannel | None = None
202
+
203
+
204
+ class PricelistDocument(Strict):
205
+ manifest: Literal["cpms/v1"]
206
+ kind: Literal["pricelist"]
207
+ organization: Key
208
+ tariffs: list[Tariff]
209
+ plans: list[Plan]
210
+ channels: Channels
211
+
212
+ @property
213
+ def org_key(self) -> str:
214
+ return self.organization
215
+
216
+ @property
217
+ def doc_key(self) -> str:
218
+ return "pricelist"
219
+
220
+ @model_validator(mode="after")
221
+ def _references(self): # type: ignore[no-untyped-def]
222
+ _unique(self.tariffs, "key", "tariff")
223
+ _unique(self.plans, "key", "plan")
224
+ tariffs = {t.key for t in self.tariffs}
225
+ plans = {p.key: p for p in self.plans}
226
+ for p in self.plans:
227
+ if p.tariff not in tariffs:
228
+ raise ValueError(f"plan {p.key!r} names unknown tariff {p.tariff!r}")
229
+ for key in self.channels.adhoc:
230
+ if key not in plans:
231
+ raise ValueError(f"channel adhoc names unknown plan {key!r}")
232
+ if plans[key].requires != "none":
233
+ raise ValueError(f"channel adhoc plan {key!r} must have requires: none")
234
+ if plans[key].preauth_amount is None:
235
+ raise ValueError(f"channel adhoc plan {key!r} needs a preauth_amount")
236
+ if self.channels.roaming and self.channels.roaming.default not in plans:
237
+ raise ValueError(f"channel roaming names unknown plan {self.channels.roaming.default!r}")
238
+ return self
239
+
240
+
241
+ # --- site -------------------------------------------------------------------
242
+
243
+
244
+ class Policy(Strict):
245
+ mode: Literal["fair_share"] = "fair_share"
246
+ min_amps: int = Field(default=6, ge=6, le=32)
247
+ max_amps: int = Field(default=32, ge=6, le=63)
248
+ single_phase_threshold_amps: int = Field(default=10, ge=6, le=32)
249
+ single_phase_amps: int = Field(default=16, ge=6, le=32)
250
+ pause_allowed: bool = True
251
+ hysteresis_amps: int = Field(default=2, ge=0, le=10)
252
+ phase_switch_cooldown_s: int = Field(default=300, ge=0)
253
+
254
+
255
+ class PostgresMeter(Strict):
256
+ type: Literal["postgres_energy_readings"]
257
+ dsn_env: str
258
+ location: str
259
+ max_age_s: int = Field(default=30, gt=0)
260
+ margin_amps: int = Field(default=2, ge=0)
261
+ fallback_external_amps: int | None = Field(default=None, ge=0)
262
+
263
+
264
+ class HttpMeter(Strict):
265
+ type: Literal["http"]
266
+ url: str
267
+ auth_header_env: str | None = None
268
+ max_age_s: int = Field(default=30, gt=0)
269
+ margin_amps: int = Field(default=2, ge=0)
270
+ fallback_external_amps: int | None = Field(default=None, ge=0)
271
+
272
+
273
+ ExternalMeter = Annotated[Union[PostgresMeter, HttpMeter], Field(discriminator="type")]
274
+
275
+
276
+ class Outlet(Strict):
277
+ connector: int = Field(ge=1, le=8)
278
+ key: Key
279
+ public: bool = True
280
+ max_amps: int | None = Field(default=None, ge=6, le=32) # the balancer's cap and the connector's rating
281
+ # What OCPI says about the outlet. Required on a published site (plan checks), defaults otherwise.
282
+ plug: Literal["type2_socket", "type2_cable"] | None = None
283
+ phases: Literal[1, 3] | None = None
284
+ evse_id: Annotated[str, StringConstraints(max_length=48)] | None = None # overrides the derived eMI3 id
285
+
286
+
287
+ class ChargePoint(Strict):
288
+ key: Key
289
+ ocpp_id: Annotated[str, StringConstraints(min_length=1, max_length=64)]
290
+ name: str | None = None
291
+ phase_rotation: list[Phase] = ["L1", "L2", "L3"]
292
+ rotation_verified: bool = False
293
+ auth: Literal["none", "basic"] = "none" # the key is generated server-side, never in the file
294
+ outlets: list[Outlet]
295
+
296
+ @model_validator(mode="after")
297
+ def _physics(self): # type: ignore[no-untyped-def]
298
+ if sorted(self.phase_rotation) != ["L1", "L2", "L3"]:
299
+ raise ValueError(f"charge point {self.key!r}: phase_rotation must be a permutation of L1, L2, L3")
300
+ if not self.outlets:
301
+ raise ValueError(f"charge point {self.key!r} has no outlets")
302
+ _unique(self.outlets, "connector", f"connector on {self.key!r}")
303
+ return self
304
+
305
+
306
+ class FuseGroup(Strict):
307
+ key: Key
308
+ name: str | None = None
309
+ physical_fuse_amps: int = Field(gt=0)
310
+ budget_amps: int = Field(gt=0)
311
+ balancing: bool = True
312
+ policy: Policy = Policy()
313
+ external_meter: ExternalMeter | None = None
314
+ charge_points: list[ChargePoint] = []
315
+
316
+ @model_validator(mode="after")
317
+ def _physics(self): # type: ignore[no-untyped-def]
318
+ if self.budget_amps > self.physical_fuse_amps:
319
+ raise ValueError(
320
+ f"fuse group {self.key!r}: budget_amps {self.budget_amps} exceeds physical_fuse_amps {self.physical_fuse_amps}"
321
+ )
322
+ _unique(self.charge_points, "key", "charge point")
323
+ return self
324
+
325
+
326
+ class Coordinates(Strict):
327
+ """WGS 84, as OCPI writes them: decimal degrees as strings with 5 to 7 decimals."""
328
+
329
+ # The validator outermost, so the pattern also reaches the JSON Schema.
330
+ latitude: Annotated[str, StringConstraints(pattern=r"^-?[0-9]{1,2}\.[0-9]{5,7}$"), BeforeValidator(_quoted)]
331
+ longitude: Annotated[str, StringConstraints(pattern=r"^-?[0-9]{1,3}\.[0-9]{5,7}$"), BeforeValidator(_quoted)]
332
+
333
+
334
+ class Site(Strict):
335
+ key: Key
336
+ name: str
337
+ address: str | None = None # street and number
338
+ city: str | None = None
339
+ postal_code: str | None = None
340
+ country: Annotated[str, StringConstraints(pattern=r"^[A-Z]{3}$")] = "SWE" # ISO 3166-1 alpha-3
341
+ coordinates: Coordinates | None = None
342
+ timezone: TimeZone | None = None # defaults to the organization's display_timezone
343
+ publish: bool = False # in OCPI (Nobil, roaming) only when true
344
+ notes: str | None = None
345
+ fuse_groups: list[FuseGroup]
346
+
347
+ @model_validator(mode="after")
348
+ def _unique_keys(self): # type: ignore[no-untyped-def]
349
+ _unique(self.fuse_groups, "key", "fuse group")
350
+ cps = [cp for fg in self.fuse_groups for cp in fg.charge_points]
351
+ _unique(cps, "key", "charge point")
352
+ _unique(cps, "ocpp_id", "ocpp_id")
353
+ _unique([o for cp in cps for o in cp.outlets], "key", "outlet")
354
+ return self
355
+
356
+
357
+ class SiteDocument(Strict):
358
+ manifest: Literal["cpms/v1"]
359
+ kind: Literal["site"]
360
+ organization: Key
361
+ site: Site
362
+
363
+ @property
364
+ def org_key(self) -> str:
365
+ return self.organization
366
+
367
+ @property
368
+ def doc_key(self) -> str:
369
+ return self.site.key
370
+
371
+
372
+ Document = Annotated[
373
+ Union[OrganizationDocument, PricelistDocument, SiteDocument], Field(discriminator="kind")
374
+ ]