Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions pypowerwall/tedapi/api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<year:4>_<month:2> with an OPTIONAL
# _<day:2> — "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<YYYY>_<MM> or "
"V<YYYY>_<MM>_<DD> (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)."""
Expand All @@ -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);
Expand All @@ -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()
230 changes: 229 additions & 1 deletion pypowerwall/tests/tedapi/test_api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -292,6 +292,234 @@ 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 declaration order, not by comparing the string labels, 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<YYYY>_<MM>[_<DD>]; 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)


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<YYYY>_<MM>"):
_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():
Expand Down
Loading