diff --git a/pypowerwall/tedapi/api_version.py b/pypowerwall/tedapi/api_version.py index ef534987..641e8839 100644 --- a/pypowerwall/tedapi/api_version.py +++ b/pypowerwall/tedapi/api_version.py @@ -6,10 +6,57 @@ variables, CLI args, dict keys, and JSON — while still being a real enum. """ import logging +import re from enum import Enum log = logging.getLogger(__name__) +# Member labels are date-encoded: V_ with an OPTIONAL +# _ — "V2026_06" or "V2026_06_01". Ordering is derived from that date +# (see TEDAPIApiVersion._rank), so the format is load-bearing, not cosmetic; a +# new member must follow it. All three components are zero-padded fixed width. +LABEL_RE = re.compile(r"^V(\d{4})_(\d{2})(?:_(\d{2}))?$") + +# Day used when a label carries no day component. Zero (not 1) so a month-only +# label is strictly distinct from — and sorts before — every dated label in the +# same month. That matches how the day would come to be used: a month-only set +# ships first, and a later set in that same month needs a day to disambiguate +# itself, so the undated one is the earlier of the two. Day 0 cannot collide +# with a real label because _parse_label only accepts days 01-31. +_NO_DAY = 0 + + +def _parse_label(label: str, owner: str = "TEDAPIApiVersion") -> tuple: + """Parse a VYYYY_MM[_DD] label into a sortable (year, month, day) triple. + + The day is optional; when absent it is reported as 0 (see _NO_DAY), which + sorts a month-only label ahead of any dated label in the same month. + + Rejects anything that does not match the format, including out-of-range + month or day values: ordering is only meaningful if the label really is a + date, so a malformed label must fail rather than sort somewhere arbitrary. + Day validation is a plain 01-31 range check, not a calendar lookup — these + are release labels, and rejecting e.g. Feb 30 would buy nothing. + """ + match = LABEL_RE.match(label) if isinstance(label, str) else None + if not match: + raise ValueError( + f"{owner} label {label!r} is not of the form V_ or " + "V__
(e.g. 'V2026_06', 'V2026_06_01'); ordering is " + "derived from that date") + year, month, day = match.group(1), match.group(2), match.group(3) + year, month = int(year), int(month) + if not 1 <= month <= 12: + raise ValueError( + f"{owner} label {label!r} has month {month:02d}, which is not 01-12") + if day is None: + return year, month, _NO_DAY + day = int(day) + if not 1 <= day <= 31: + raise ValueError( + f"{owner} label {label!r} has day {day:02d}, which is not 01-31") + return year, month, day + class TEDAPIApiVersion(str, Enum): """Which TEDAPI query + protobuf set to use (date-labeled, not APK version).""" @@ -20,6 +67,53 @@ def __str__(self) -> str: # Stable display across Python versions (avoids "TEDAPIApiVersion.V2024_06"). return self.value + # --- ordering ----------------------------------------------------------- + # Order is derived from the (year, month, day) the label encodes, NOT from + # declaration order and NOT from str's lexical comparison. Parsing the date + # means a member inserted out of sequence still sorts correctly, and it makes + # the VYYYY_MM[_DD] format an enforced contract rather than a convention: + # anything that does not match it is rejected. + + def _rank(self) -> tuple: + """(year, month, day) parsed from this member's VYYYY_MM[_DD] label.""" + return _parse_label(self.value, type(self).__name__) + + @classmethod + def _rank_of(cls, other) -> tuple: + """Rank of a member or a known version string; TypeError on anything else. + + Raising beats returning NotImplemented here. Members are str subclasses, + so NotImplemented would NOT produce a TypeError — Python would fall back + to plain str comparison and answer from lexical order, silently. A + lowercase typo is the nasty case: 'v2026_06' > 'V2026_06' in ASCII, so + the comparison quietly reverses. That is the exact fragility these + operators exist to remove, so a non-version operand fails loudly. + + The operand must be a member, not merely a well-formed label: this enum + is the closed set of query/protobuf sets the library actually ships, so + ordering against a version it cannot serve is a bug worth surfacing. + """ + if isinstance(other, cls): + return other._rank() + try: + return cls(other)._rank() + except (ValueError, KeyError, TypeError): + raise TypeError( + f"cannot order {cls.__name__} against {other!r}: not a known " + f"version ({', '.join(m.value for m in cls)})") from None + + def __lt__(self, other): + return self._rank() < self._rank_of(other) + + def __le__(self, other): + return self._rank() <= self._rank_of(other) + + def __gt__(self, other): + return self._rank() > self._rank_of(other) + + def __ge__(self, other): + return self._rank() >= self._rank_of(other) + @classmethod def coerce(cls, value) -> "TEDAPIApiVersion": """Accept a TEDAPIApiVersion or a string (e.g. from an env var / CLI); @@ -40,3 +134,17 @@ def coerce(cls, value) -> "TEDAPIApiVersion": value, cls.V2024_06.value, ", ".join(m.value for m in cls), ) return cls.V2024_06 + + +def _validate_member_labels() -> None: + """Enforce the label contract at import. + + Ordering parses member labels as dates, so a member that does not match + VYYYY_MM[_DD] would break comparisons at some arbitrary later call site. + Failing at import points straight at the offending member instead. + """ + for member in TEDAPIApiVersion: + _parse_label(member.value) + + +_validate_member_labels() diff --git a/pypowerwall/tests/tedapi/test_api_version.py b/pypowerwall/tests/tedapi/test_api_version.py index 5c41ba8b..170a3851 100644 --- a/pypowerwall/tests/tedapi/test_api_version.py +++ b/pypowerwall/tests/tedapi/test_api_version.py @@ -10,7 +10,7 @@ from pypowerwall.tedapi import TEDAPI, tedapi_pb2 from pypowerwall.tedapi import queries as q -from pypowerwall.tedapi.api_version import TEDAPIApiVersion +from pypowerwall.tedapi.api_version import LABEL_RE, TEDAPIApiVersion, _parse_label # The V2026_06 pb2 requires protobuf>=6.33.6 (guarded gencode); the default path # stays on the 4.25.1 floor, so import lazily and skip the build/parse tests when @@ -292,6 +292,235 @@ def test_parse_signed_response_v1r_wifi_without_flag_drops_payload(api): assert api._parse_signed_query_response(raw, from_wifi=False) is None +# --- TEDAPIApiVersion ordering ---------------------------------------------- +# Ordering is by the date parsed from each member's label, not by comparing the +# string labels lexically, so a future version whose label does not sort +# lexically still ranks correctly. + +V24, V26 = TEDAPIApiVersion.V2024_06, TEDAPIApiVersion.V2026_06 + + +def test_ordering_between_members(): + assert V24 < V26 + assert V26 > V24 + assert V24 <= V26 and V26 >= V24 + assert not (V26 < V24) + assert not (V24 > V26) + + +def test_ordering_is_reflexive_for_le_ge(): + assert V26 <= V26 and V26 >= V26 + assert not (V26 < V26) + assert not (V26 > V26) + + +def test_threshold_semantics(): + """Capability-threshold semantics: only versions OLDER than the floor fail + it; the floor itself, and anything newer, satisfy it.""" + assert V24 < V26 # older than the floor + assert not (V26 < V26) # exactly the floor + assert V26 >= V26 # floor satisfies its own threshold + + +def test_ordering_against_plain_strings(): + """Version strings are the documented input form (env var / CLI), so they + must order correctly from either side of the operator.""" + assert V24 < "V2026_06" + assert V26 >= "V2024_06" + # reflected: a plain str on the left must still use enum ordering, because + # TEDAPIApiVersion is a str subclass and Python tries its reflected op first + assert "V2024_06" < V26 + assert not ("V2026_06" < V24) + + +def test_rank_is_the_parsed_date(): + """Ordering is derived from the date the label encodes, not from declaration + order and not from lexical string order. Day 0 marks a month-only label.""" + assert TEDAPIApiVersion.V2024_06._rank() == (2024, 6, 0) + assert TEDAPIApiVersion.V2026_06._rank() == (2026, 6, 0) + + +# --- rank tuple shape ------------------------------------------------------- +# Comparison is Python's tuple comparison: scan with == to the first differing +# index, then apply the operator to that pair alone, falling back to length if +# no index differs. Two properties of the rank tuples are what make that give +# correct date ordering, and neither is guaranteed by the ordering tests below — +# so pin them directly. + +@pytest.mark.parametrize("label", [ + "V2024_06", "V2026_06", "V2026_06_01", "V2026_06_31", "V9999_12_31", "V0001_01", +]) +def test_rank_is_always_three_ints(label): + """Uniform arity and homogeneous int elements. + + Arity: tuples of different lengths still ORDER sanely (a prefix sorts + first), so a ragged rank would slip past every ordering assertion — but + (2026, 6) != (2026, 6, 0), so equality would silently break instead. + + Types: tuple comparison reaches the first differing pair and applies the + operator to it, so a stray str or None in a rank raises TypeError mid-compare + rather than misordering. Both failure modes are invisible to sort tests. + """ + rank = _parse_label(label) + assert isinstance(rank, tuple) + assert len(rank) == 3, f"{label!r} produced a {len(rank)}-tuple; ranks must be uniform" + assert all(isinstance(part, int) for part in rank), \ + f"{label!r} produced non-int parts {rank!r}; comparison would raise" + + +def test_every_member_rank_is_three_ints(): + for member in TEDAPIApiVersion: + rank = member._rank() + assert len(rank) == 3 and all(isinstance(p, int) for p in rank), \ + f"{member.value!r} rank {rank!r} is not a 3-int tuple" + + +# --- lexicographic precedence ----------------------------------------------- +# The first differing component decides the whole comparison, so significance +# must fall off year -> month -> day. These cases are adversarial: the less +# significant components point the OPPOSITE way to the correct answer, so they +# fail if precedence is ever inverted or flattened. +# +# Day-level cases go through _parse_label rather than the enum operators: both +# shipped members are month-only, and the operators deliberately reject a label +# that is not a member, so there is no day-bearing member to compare against. +# _parse_label is where the ordering actually comes from, so that is the right +# level for these. + +def test_year_dominates_month_and_day(): + """A late month/day in an earlier year must still sort first.""" + assert _parse_label("V2025_12_31") < _parse_label("V2026_01") + assert _parse_label("V2025_12_31") < _parse_label("V2026_01_01") + assert not (_parse_label("V2026_01_01") < _parse_label("V2025_12_31")) + + +def test_month_dominates_day(): + """The last day of a month must still sort before the first of the next.""" + assert _parse_label("V2026_06_31") < _parse_label("V2026_07_01") + assert _parse_label("V2026_06_31") < _parse_label("V2026_07") + assert not (_parse_label("V2026_07_01") < _parse_label("V2026_06_31")) + + +def test_day_decides_only_when_year_and_month_tie(): + assert _parse_label("V2026_06_02") < _parse_label("V2026_06_10") + # ...and cannot flip a decision already made by year or month + assert _parse_label("V2026_06_28") < _parse_label("V2027_06_01") + + +def test_sorting_is_monotonic_across_all_three_components(): + """One shuffled list exercising year, month and day transitions at once.""" + labels = [ + "V2026_07_01", "V2024_06", "V2026_06_28", "V2025_12_31", + "V2026_06", "V2026_06_02", "V2027_01", "V2026_01", + ] + ordered = sorted(labels, key=_parse_label) + assert ordered == [ + "V2024_06", "V2025_12_31", "V2026_01", "V2026_06", + "V2026_06_02", "V2026_06_28", "V2026_07_01", "V2027_01", + ] + for older, newer in zip(ordered, ordered[1:]): + assert _parse_label(older) < _parse_label(newer), \ + f"{older} must sort strictly before {newer}" + + +def test_member_ranks_are_unique(): + """Distinct members must not tie. A tie makes both `a < b` and `a > b` false, + which silently breaks sorting and threshold checks.""" + ranks = [m._rank() for m in TEDAPIApiVersion] + assert len(set(ranks)) == len(ranks), f"duplicate ranks among members: {ranks}" + + +def test_every_member_label_matches_the_format(): + """The VYYYY_MM[_DD] format is load-bearing — ordering parses it — so a new + member that breaks it must be caught here (and at import).""" + for member in TEDAPIApiVersion: + assert LABEL_RE.match(member.value), \ + f"{member.value!r} is not V_[_
]; ordering would break" + + +def test_ordering_sorts_by_date_across_declaration_order(): + """A member declared out of sequence must still sort by its date. Built from + labels rather than the live enum so the property holds for future members.""" + labels = ["V2027_01", "V2024_06", "V2026_06", "V2024_11"] + assert sorted(labels, key=_parse_label) == \ + ["V2024_06", "V2024_11", "V2026_06", "V2027_01"] + + +# --- optional day component ------------------------------------------------- +# Labels may carry a day: V2026_06_01. Not used today, but the format must +# accept and order it correctly if a future set needs same-month precision. + +def test_day_component_is_parsed(): + assert _parse_label("V2026_06_01") == (2026, 6, 1) + assert _parse_label("V2026_06_15") == (2026, 6, 15) + assert _parse_label("V2026_06_31") == (2026, 6, 31) + + +def test_month_only_label_sorts_before_dated_labels_in_that_month(): + """The deliberate convention: a day-less label ranks at day 0, so it is + strictly distinct from — and earlier than — any dated label in the same + month. A month-only set ships first; a later set that same month is the one + that needs a day to disambiguate itself.""" + assert _parse_label("V2026_06") < _parse_label("V2026_06_01") + assert _parse_label("V2026_06") != _parse_label("V2026_06_01"), \ + "a month-only label must not rank equal to a dated one (ties break ordering)" + + +def test_day_does_not_leak_across_month_or_year_boundaries(): + labels = ["V2026_07_01", "V2026_06", "V2026_06_28", "V2025_12_31", "V2026_06_02"] + assert sorted(labels, key=_parse_label) == [ + "V2025_12_31", "V2026_06", "V2026_06_02", "V2026_06_28", "V2026_07_01"] + + +def test_dated_labels_order_within_a_month(): + assert _parse_label("V2026_06_02") < _parse_label("V2026_06_10") + assert _parse_label("V2026_06_10") < _parse_label("V2026_06_28") + + +@pytest.mark.parametrize("bad", [ + "v2026_06", # lowercase: sorts ABOVE uppercase lexically, silently reversing + "V2026_6", # unpadded month + "V26_06", # short year + "V2026-06", # wrong separator + "V2026_13", # month out of range + "V2026_00", + "V2026_06_1", # unpadded day + "V2026_06_32", # day out of range + "V2026_06_00", # day 0 is the no-day sentinel, never a valid label + "V2026_06_", # trailing separator, no day + "V2026_06_01_02", # too many components + "nonsense", "", 3, None, +]) +def test_parse_label_rejects_non_conforming_values(bad): + with pytest.raises(ValueError): + _parse_label(bad) # type: ignore[arg-type] + + +def test_ordering_rejects_non_version_operand(): + """Comparing against a non-version must raise with a useful message, not + fall back to str order (NotImplemented would do exactly that, since members + are str subclasses).""" + for bad in ("V9999_99", "v2026_06", "nonsense", 3): + with pytest.raises(TypeError) as exc: + _ = TEDAPIApiVersion.V2024_06 < bad + assert "not a known version" in str(exc.value) + assert "V2026_06" in str(exc.value), "error must list the valid versions" + + +def test_malformed_member_label_is_rejected_at_import(): + """The import-time guard: a member that breaks the format must be refused.""" + with pytest.raises(ValueError, match="V_"): + _parse_label("V26_6", "TEDAPIApiVersion") + + +def test_equality_and_dict_use_still_string_based(): + """Only ordering is overridden — equality and hashing stay str's, so members + keep comparing equal to their plain string and keep working as dict keys.""" + assert V26 == "V2026_06" + assert {"V2026_06": 1}[V26] == 1 + assert {V26: 1}["V2026_06"] == 1 + + # --- back-compat import shims ----------------------------------------------- def test_legacy_pb2_deep_import_paths_still_resolve():