minutemap 0.1.0__tar.gz

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.
@@ -0,0 +1,115 @@
1
+ name: Publish Python distribution to PyPI
2
+ on:
3
+ push:
4
+ tags:
5
+ - 'v*'
6
+ workflow_dispatch:
7
+ jobs:
8
+ build:
9
+ name: Build distribution 📦
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - name: Set up Python
14
+ uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.x"
17
+ - name: Install hatch
18
+ run: pip install hatch
19
+ - name: Build a binary wheel and a source tarball
20
+ run: hatch build
21
+ - name: Store the distribution packages
22
+ uses: actions/upload-artifact@v4
23
+ with:
24
+ name: python-package-distributions
25
+ path: dist/
26
+ publish-to-pypi:
27
+ name: Publish to PyPI
28
+ needs: build
29
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
30
+ runs-on: ubuntu-latest
31
+ environment:
32
+ name: pypi
33
+ url: https://pypi.org/p/minutemap
34
+ permissions:
35
+ id-token: write # mandatory for trusted publishing and attestations
36
+ steps:
37
+ - name: Download all the dists
38
+ uses: actions/download-artifact@v4
39
+ with:
40
+ name: python-package-distributions
41
+ path: dist/
42
+ - name: Publish distribution to PyPI
43
+ uses: pypa/gh-action-pypi-publish@release/v1
44
+ with:
45
+ attestations: true
46
+ skip-existing: true
47
+ verbose: true
48
+ github-release:
49
+ name: Sign with Sigstore and upload to GitHub Release
50
+ needs: build
51
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
52
+ runs-on: ubuntu-latest
53
+ permissions:
54
+ contents: write # mandatory for making GitHub Releases
55
+ id-token: write # mandatory for sigstore
56
+ steps:
57
+ - name: Download all the dists
58
+ uses: actions/download-artifact@v4
59
+ with:
60
+ name: python-package-distributions
61
+ path: dist/
62
+ - name: Check if release already exists
63
+ id: check_release
64
+ run: |
65
+ if gh release view ${{ github.ref_name }} >/dev/null 2>&1; then
66
+ echo "release_exists=true" >> $GITHUB_OUTPUT
67
+ else
68
+ echo "release_exists=false" >> $GITHUB_OUTPUT
69
+ fi
70
+ env:
71
+ GITHUB_TOKEN: ${{ github.token }}
72
+ - name: Sign the dists with Sigstore
73
+ if: steps.check_release.outputs.release_exists == 'false'
74
+ uses: sigstore/gh-action-sigstore-python@v3.0.0
75
+ with:
76
+ inputs: >-
77
+ ./dist/*.tar.gz
78
+ ./dist/*.whl
79
+ - name: Create GitHub Release
80
+ if: steps.check_release.outputs.release_exists == 'false'
81
+ env:
82
+ GITHUB_TOKEN: ${{ github.token }}
83
+ run: >-
84
+ gh release create
85
+ '${{ github.ref_name }}'
86
+ --repo '${{ github.repository }}'
87
+ --notes ""
88
+ - name: Upload artifact signatures to GitHub Release
89
+ if: always()
90
+ env:
91
+ GITHUB_TOKEN: ${{ github.token }}
92
+ run: >-
93
+ gh release upload
94
+ '${{ github.ref_name }}' dist/**
95
+ --repo '${{ github.repository }}'
96
+ --clobber
97
+ # publish-to-testpypi:
98
+ # name: Publish to TestPyPI
99
+ # needs: build
100
+ # runs-on: ubuntu-latest
101
+ # environment:
102
+ # name: testpypi
103
+ # url: https://test.pypi.org/p/minitemap
104
+ # permissions:
105
+ # id-token: write
106
+ # steps:
107
+ # - name: Download all the dists
108
+ # uses: actions/download-artifact@v4
109
+ # with:
110
+ # name: python-package-distributions
111
+ # path: dist/
112
+ # - name: Publish distribution to TestPyPI
113
+ # uses: pypa/gh-action-pypi-publish@release/v1
114
+ # with:
115
+ # repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: minutemap
3
+ Version: 0.1.0
4
+ Summary: Provides a data type for mapping a value to every minute of a year and then resolve a value given a date
5
+ Project-URL: Homepage, https://github.com/sgpinkue/minutemap
6
+ Project-URL: Repository, https://github.com/sgpinkus/minutemap
7
+ Project-URL: Issues, https://github.com/sgpinkus/minutemap/issues
8
+ License: MIT
9
+ Keywords: calendar,home-assistant,schedule,time
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Home Automation
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # (YEARLY) MINUTE MAP
23
+ The idea is you can specify a value (`int` by default) for any and every minute of an entire year. Which particular year isn't representable. Hierarchical specifiiers and priority matching rules are employed to determine the value for any given minute of the year. A specification takes the form of a set of `<expression, value>` pairs in JSON or as a dictionary.
24
+
25
+ An `expression` is a dot-separated sequence of time tokens (coarse -> fine). The wildcard "\*" may appear as a standalone spec or as a leaf segment, and is equivalent to truncating the path there (i.e. "h19.*" == "h19").
26
+
27
+ The method `YearMinuteMap.get_value()` takes a `datetime`, and returns a value by selecting the most specific matching spec's value . Example:
28
+
29
+ ```
30
+ my_minute_map = {
31
+ "*": 8,
32
+ "h5-10": 28,
33
+ "h19": {
34
+ "*": 18,
35
+ "m30-59": 22
36
+ },
37
+ "q2": {
38
+ "h0-4": 10,
39
+ "h5-10": 30,
40
+ "h11-18": 10,
41
+ "h19-23": 20,
42
+ },
43
+ "q3": {
44
+ "h0-4": 12,
45
+ "h5-10": 32,
46
+ "h11-18": 12,
47
+ "h19-23": 20,
48
+ "sun": {
49
+ "h0-4": 14,
50
+ "h5-10": 34,
51
+ "h11-18": 14,
52
+ "h19-23": 23,
53
+ }
54
+ }
55
+ }
56
+ spec = YearMinuteMap(my_minute_map)
57
+ spec.get_value(my_date) # -> value
58
+ ```
59
+
60
+ Input may be a flat or arbitrarily nested dict; nested dicts are flattened by joining their key paths with ".". Both dict and JSON string are accepted. Flat Example:
61
+
62
+
63
+ ```
64
+ {
65
+ "*": 1,
66
+ "q1": 2,
67
+ "q1.sun.h1-10.m1": 3
68
+ "q1.sun.h1-10.m2": 4
69
+ }
70
+ ```
71
+
72
+ **EBNF for expressions:**
73
+
74
+ ```
75
+ SPEC ::= "*" | PATH
76
+ PATH ::=
77
+ ( QTR | ( "." ( _DOM | _DOW | _HH | MM ) )? )
78
+ | ( MOY | ( "." ( _DOM | _DOW | _HH | MM ) )? )
79
+ | WOY ( "." ( _DOW | _HH | MM ) )?
80
+ | DOY ( "." ( _HH | MM ) )?
81
+ | _DOM
82
+ | _DOW
83
+ | _HH
84
+ | MM
85
+ _DOM = DOM ( "." ( _HH | MM ) )?
86
+ _DOW = DOW ( "." ( _HH | MM ) )?
87
+ _HH = HH ( "." MM )?
88
+ QTR ::= "q1" | "q2" | "q3" | "q4"
89
+ MOY ::= "moy" RANGE // 1-12
90
+ WOY ::= "woy" RANGE // 1–53 (ISO weeks can be 53)
91
+ DOY ::= "doy" RANGE // 1–366
92
+ DOM ::= "dom" RANGE // 1–31
93
+ DOW ::= "dow" RANGE // 1-7
94
+ HH ::= "h" RANGE // 0–23
95
+ MM ::= "m" RANGE // 0–59
96
+ RANGE ::= DIGITS | DIGITS "-" DIGITS
97
+ ```
98
+
99
+ The grammar does not allow use of the same type of token twice (ex "dom12.dom13", "q1.apr") and enforces a hierarchy.
100
+
101
+ MOY and DOW have aliases MONTH and WEEKDAY not shown in EBNF:
102
+
103
+ ```
104
+ MONTH ::= "jan" | "feb" | "mar" | "apr" | "may" | "jun" |
105
+ "jul" | "aug" | "sep" | "oct" | "nov" | "dec"
106
+ WEEKDAY ::= "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"
107
+ ```
108
+
109
+ **Token reference:**
110
+
111
+ q1..q4 Quarter (maps to moy range internally)
112
+ moy<range> Month of year 1-12 (aliases: jan…dec)
113
+ woy<range> ISO week 1-53
114
+ doy<range> Day of year 1-366
115
+ dom<range> Day of month 1-31
116
+ dow<range> Day of week 1-7 (aliases: mon…sun, 1=Mon)
117
+ h<range> Hour 0-23
118
+ m<range> Minute 0-59
119
+ * Wildcard leaf — "match everything from here"
120
+ RANGE ::= DIGITS | DIGITS "-" DIGITS
121
+
122
+ Longer paths beat shorter ones and this order is used for tie breaks (TODO: allow user to specify ordering):
123
+
124
+ QTR < MOY < DOW < DOM < WOY < DOY < HH < MM
@@ -0,0 +1,103 @@
1
+ # (YEARLY) MINUTE MAP
2
+ The idea is you can specify a value (`int` by default) for any and every minute of an entire year. Which particular year isn't representable. Hierarchical specifiiers and priority matching rules are employed to determine the value for any given minute of the year. A specification takes the form of a set of `<expression, value>` pairs in JSON or as a dictionary.
3
+
4
+ An `expression` is a dot-separated sequence of time tokens (coarse -> fine). The wildcard "\*" may appear as a standalone spec or as a leaf segment, and is equivalent to truncating the path there (i.e. "h19.*" == "h19").
5
+
6
+ The method `YearMinuteMap.get_value()` takes a `datetime`, and returns a value by selecting the most specific matching spec's value . Example:
7
+
8
+ ```
9
+ my_minute_map = {
10
+ "*": 8,
11
+ "h5-10": 28,
12
+ "h19": {
13
+ "*": 18,
14
+ "m30-59": 22
15
+ },
16
+ "q2": {
17
+ "h0-4": 10,
18
+ "h5-10": 30,
19
+ "h11-18": 10,
20
+ "h19-23": 20,
21
+ },
22
+ "q3": {
23
+ "h0-4": 12,
24
+ "h5-10": 32,
25
+ "h11-18": 12,
26
+ "h19-23": 20,
27
+ "sun": {
28
+ "h0-4": 14,
29
+ "h5-10": 34,
30
+ "h11-18": 14,
31
+ "h19-23": 23,
32
+ }
33
+ }
34
+ }
35
+ spec = YearMinuteMap(my_minute_map)
36
+ spec.get_value(my_date) # -> value
37
+ ```
38
+
39
+ Input may be a flat or arbitrarily nested dict; nested dicts are flattened by joining their key paths with ".". Both dict and JSON string are accepted. Flat Example:
40
+
41
+
42
+ ```
43
+ {
44
+ "*": 1,
45
+ "q1": 2,
46
+ "q1.sun.h1-10.m1": 3
47
+ "q1.sun.h1-10.m2": 4
48
+ }
49
+ ```
50
+
51
+ **EBNF for expressions:**
52
+
53
+ ```
54
+ SPEC ::= "*" | PATH
55
+ PATH ::=
56
+ ( QTR | ( "." ( _DOM | _DOW | _HH | MM ) )? )
57
+ | ( MOY | ( "." ( _DOM | _DOW | _HH | MM ) )? )
58
+ | WOY ( "." ( _DOW | _HH | MM ) )?
59
+ | DOY ( "." ( _HH | MM ) )?
60
+ | _DOM
61
+ | _DOW
62
+ | _HH
63
+ | MM
64
+ _DOM = DOM ( "." ( _HH | MM ) )?
65
+ _DOW = DOW ( "." ( _HH | MM ) )?
66
+ _HH = HH ( "." MM )?
67
+ QTR ::= "q1" | "q2" | "q3" | "q4"
68
+ MOY ::= "moy" RANGE // 1-12
69
+ WOY ::= "woy" RANGE // 1–53 (ISO weeks can be 53)
70
+ DOY ::= "doy" RANGE // 1–366
71
+ DOM ::= "dom" RANGE // 1–31
72
+ DOW ::= "dow" RANGE // 1-7
73
+ HH ::= "h" RANGE // 0–23
74
+ MM ::= "m" RANGE // 0–59
75
+ RANGE ::= DIGITS | DIGITS "-" DIGITS
76
+ ```
77
+
78
+ The grammar does not allow use of the same type of token twice (ex "dom12.dom13", "q1.apr") and enforces a hierarchy.
79
+
80
+ MOY and DOW have aliases MONTH and WEEKDAY not shown in EBNF:
81
+
82
+ ```
83
+ MONTH ::= "jan" | "feb" | "mar" | "apr" | "may" | "jun" |
84
+ "jul" | "aug" | "sep" | "oct" | "nov" | "dec"
85
+ WEEKDAY ::= "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"
86
+ ```
87
+
88
+ **Token reference:**
89
+
90
+ q1..q4 Quarter (maps to moy range internally)
91
+ moy<range> Month of year 1-12 (aliases: jan…dec)
92
+ woy<range> ISO week 1-53
93
+ doy<range> Day of year 1-366
94
+ dom<range> Day of month 1-31
95
+ dow<range> Day of week 1-7 (aliases: mon…sun, 1=Mon)
96
+ h<range> Hour 0-23
97
+ m<range> Minute 0-59
98
+ * Wildcard leaf — "match everything from here"
99
+ RANGE ::= DIGITS | DIGITS "-" DIGITS
100
+
101
+ Longer paths beat shorter ones and this order is used for tie breaks (TODO: allow user to specify ordering):
102
+
103
+ QTR < MOY < DOW < DOM < WOY < DOY < HH < MM
@@ -0,0 +1,4 @@
1
+ from minutemap.main import YearMinuteMap, ParseError, parse_spec
2
+
3
+ __all__ = ["YearMinuteMap", "ParseError", "parse_spec"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,368 @@
1
+ """
2
+ YearMinuteMap — hierarchical minute-resolution value scheduling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ TOKEN_SPECIFICITY: dict[str, int] = {
13
+ "QTR": 10,
14
+ "MOY": 20,
15
+ "DOW": 30,
16
+ "DOM": 40,
17
+ "WOY": 50,
18
+ "DOY": 60,
19
+ "HH": 70,
20
+ "MM": 80,
21
+ }
22
+ TOKEN_ALLOWED_CHILDREN: dict[str, set[str]] = {
23
+ "QTR": {"DOM", "DOW", "HH", "MM"},
24
+ "MOY": {"DOM", "DOW", "HH", "MM"},
25
+ "WOY": {"DOW", "HH", "MM"},
26
+ "DOY": {"HH", "MM"},
27
+ "DOM": {"HH", "MM"},
28
+ "DOW": {"HH", "MM"},
29
+ "HH": {"MM"},
30
+ "MM": set(),
31
+ }
32
+ TOKEN_VALID_ROOTS = {"QTR", "MOY", "WOY", "DOY", "DOM", "DOW", "HH", "MM"}
33
+ MONTH_ALIASES: dict[str, int] = {
34
+ "jan": 1, "feb": 2, "mar": 3, "apr": 4,
35
+ "may": 5, "jun": 6, "jul": 7, "aug": 8,
36
+ "sep": 9, "oct": 10, "nov": 11, "dec": 12,
37
+ }
38
+ DOW_ALIASES: dict[str, int] = {
39
+ "mon": 1, "tue": 2, "wed": 3, "thu": 4,
40
+ "fri": 5, "sat": 6, "sun": 7,
41
+ }
42
+ QTR_MONTHS: dict[str, tuple[int, int]] = {
43
+ "q1": (1, 3), "q2": (4, 6), "q3": (7, 9), "q4": (10, 12),
44
+ }
45
+ # Tried in order after aliases; first prefix match wins.
46
+ _TOKEN_PATTERNS: list[tuple[str, str, int, int]] = [
47
+ ("moy", "MOY", 1, 12),
48
+ ("woy", "WOY", 1, 53),
49
+ ("doy", "DOY", 1, 366),
50
+ ("dom", "DOM", 1, 31),
51
+ ("dow", "DOW", 1, 7),
52
+ ("h", "HH", 0, 23),
53
+ ("m", "MM", 0, 59),
54
+ ]
55
+
56
+ @dataclass(frozen=True)
57
+ class Token:
58
+ """ Token dataclass """
59
+ kind: str
60
+ lo: int
61
+ hi: int
62
+
63
+ def matches(self, value: int) -> bool:
64
+ return self.lo <= value <= self.hi
65
+
66
+
67
+ # Empty tuple == wildcard (matches everything).
68
+ ParsedPath = tuple[Token, ...]
69
+
70
+ class ParseError(ValueError):
71
+ pass
72
+
73
+
74
+ def _parse_range(text: str, lo_bound: int, hi_bound: int, kind: str) -> tuple[int, int]:
75
+ m = re.fullmatch(r'(\d+)(?:-(\d+))?', text)
76
+ if not m:
77
+ raise ParseError(f"Invalid range '{text}' for {kind}")
78
+ lo = int(m.group(1))
79
+ hi = int(m.group(2)) if m.group(2) else lo
80
+ if lo > hi:
81
+ raise ParseError(f"Range lo > hi in '{text}' for {kind}")
82
+ if lo < lo_bound or hi > hi_bound:
83
+ raise ParseError(
84
+ f"Range {lo}-{hi} out of bounds [{lo_bound},{hi_bound}] for {kind}"
85
+ )
86
+ return lo, hi
87
+
88
+
89
+ def _parse_part(part: str) -> Token:
90
+ """ Parse one dot-separated segment into a Token. """
91
+ low = part.lower()
92
+
93
+ if low in QTR_MONTHS:
94
+ lo, hi = QTR_MONTHS[low]
95
+ return Token("QTR", lo, hi)
96
+
97
+ if low in MONTH_ALIASES:
98
+ v = MONTH_ALIASES[low]
99
+ return Token("MOY", v, v)
100
+
101
+ if low in DOW_ALIASES:
102
+ v = DOW_ALIASES[low]
103
+ return Token("DOW", v, v)
104
+
105
+ for prefix, kind, lb, ub in _TOKEN_PATTERNS:
106
+ if low.startswith(prefix):
107
+ range_text = low[len(prefix):]
108
+ if not range_text:
109
+ raise ParseError(f"Missing range after '{prefix}' in '{part}'")
110
+ lo, hi = _parse_range(range_text, lb, ub, kind)
111
+ return Token(kind, lo, hi)
112
+
113
+ raise ParseError(f"Unrecognised token '{part}'")
114
+
115
+
116
+ def parse_spec(spec: str) -> ParsedPath:
117
+ """ Parse a spec string into an ordered tuple of Tokens (coarse → fine).
118
+ Returns an empty tuple for a bare wildcard ("*"). A trailing ".*" leaf is
119
+ stripped before parsing ("h19.*" == "h19"). """
120
+ s = spec.strip()
121
+
122
+ if s == "*":
123
+ return ()
124
+
125
+ parts = s.split(".")
126
+
127
+ # Strip a trailing "*" leaf
128
+ if parts[-1] == "*":
129
+ parts = parts[:-1]
130
+
131
+ if not parts:
132
+ return ()
133
+
134
+ tokens: list[Token] = []
135
+ prev_token = None
136
+
137
+ for part in parts:
138
+ tok = _parse_part(part)
139
+ if prev_token:
140
+ allowed = TOKEN_ALLOWED_CHILDREN[prev_token.kind]
141
+ if tok.kind not in allowed:
142
+ raise ParseError(
143
+ f"'{part}' cannot follow '{prev_token.kind}' in spec '{spec}'"
144
+ )
145
+ tokens.append(tok)
146
+ prev_token = tok
147
+
148
+ return tuple(tokens)
149
+
150
+
151
+ def _flatten(node: Any, prefix: list[str], out: list[tuple[str, int]], value_cls: object) -> None:
152
+ """ Recursively walk a nested dict, collecting (spec_string, int_value) pairs.
153
+ A "*" key at any level does not contribute a path segment (leaf wildcard). """
154
+ if isinstance(node, value_cls):
155
+ spec = ".".join(prefix) if prefix else "*"
156
+ out.append((spec, node))
157
+ elif isinstance(node, dict):
158
+ for key, child in node.items():
159
+ if key == "*":
160
+ _flatten(child, prefix, out, value_cls) # "*" adds nothing to path
161
+ else:
162
+ _flatten(child, prefix + [key], out, value_cls)
163
+ else:
164
+ raise ValueError(
165
+ f"Expected {value_cls.__name__} or dict, got {type(node).__name__} "
166
+ f"at path '{'.'.join(prefix) or '*'}'"
167
+ )
168
+
169
+
170
+ def _specificity(path: ParsedPath) -> tuple[int, int]:
171
+ """ (depth, max_rank) - longer paths win; ties broken by finest token rank.
172
+ Empty path (wildcard) scores (0, 0). """
173
+ if not path:
174
+ return (0, 0)
175
+ return (len(path), max(TOKEN_SPECIFICITY[t.kind] for t in path))
176
+
177
+
178
+ def _matches_path(path: ParsedPath, dt: datetime) -> bool:
179
+ """ Return True if every token in the path matches the datetime. """
180
+ if not path:
181
+ return True # wildcard
182
+
183
+ iso_year, iso_week, iso_dow = dt.isocalendar()
184
+ doy = dt.timetuple().tm_yday
185
+
186
+ for tok in path:
187
+ if tok.kind in ("QTR", "MOY"):
188
+ if not tok.matches(dt.month):
189
+ return False
190
+ elif tok.kind == "WOY":
191
+ if not tok.matches(iso_week):
192
+ return False
193
+ elif tok.kind == "DOY":
194
+ if not tok.matches(doy):
195
+ return False
196
+ elif tok.kind == "DOM":
197
+ if not tok.matches(dt.day):
198
+ return False
199
+ elif tok.kind == "DOW":
200
+ if not tok.matches(iso_dow):
201
+ return False
202
+ elif tok.kind == "HH":
203
+ if not tok.matches(dt.hour):
204
+ return False
205
+ elif tok.kind == "MM":
206
+ if not tok.matches(dt.minute):
207
+ return False
208
+ return True
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Public API
213
+ # ---------------------------------------------------------------------------
214
+
215
+ class YearMinuteMap:
216
+ """
217
+ Parse and stash spec. Provide get_value() to resolve a datetime to an integer
218
+ value using a hierarchical spec. Input may be a flat or nested dict, or a
219
+ JSON string of either.
220
+
221
+ Example::
222
+
223
+ ymm = YearMinuteMap({
224
+ "*": 8,
225
+ "h5-10": 28,
226
+ "h19": {
227
+ "*": 18,
228
+ "m30-59": 22,
229
+ },
230
+ "q2": {
231
+ "h0-4": 10,
232
+ "h5-10": 30,
233
+ },
234
+ })
235
+ ymm.get_value(datetime(2024, 5, 1, 19, 45)) # -> 22
236
+
237
+ Example::
238
+
239
+ ymm = YearMinuteMap({"*": 7, "q1": 23, "q1.sun.h5.m30": 99})
240
+ ymm.get_value(datetime(2024, 1, 7, 5, 30)) # -> 99
241
+
242
+ """
243
+
244
+ def __init__(self, spec: str | dict, value_cls: object = int) -> None:
245
+ if isinstance(value_cls, dict):
246
+ raise ValueError("value_cls must not be dict")
247
+ self.value_cls = value_cls
248
+ if isinstance(spec, str):
249
+ try:
250
+ raw: Any = json.loads(spec)
251
+ except json.JSONDecodeError as e:
252
+ raise ValueError(f"Invalid JSON: {e}") from e
253
+ else:
254
+ raw = spec
255
+
256
+ if not isinstance(raw, dict):
257
+ raise ValueError("Spec must be a JSON object / dict")
258
+
259
+ flat: list[tuple[str, int]] = []
260
+ _flatten(raw, [], flat, value_cls)
261
+
262
+ self._entries: list[tuple[ParsedPath, int]] = []
263
+ for spec_str, value in flat:
264
+ try:
265
+ path = parse_spec(spec_str)
266
+ except ParseError as e:
267
+ raise ValueError(f"Invalid spec '{spec_str}': {e}") from e
268
+ self._entries.append((path, value))
269
+
270
+ self._entries.sort(key=lambda e: _specificity(e[0]), reverse=True)
271
+
272
+ def get_value(self, dt: datetime) -> int | None:
273
+ """ Return the value of the most specific matching spec, or None. """
274
+ for path, value in self._entries:
275
+ if _matches_path(path, dt):
276
+ return value
277
+ return None
278
+
279
+ def get_matching_spec(self, dt: datetime) -> str | None:
280
+ """ Return the canonical spec string that matched, or None. """
281
+ for path, value in self._entries:
282
+ if _matches_path(path, dt):
283
+ return _path_to_str(path)
284
+ return None
285
+
286
+ def __repr__(self) -> str:
287
+ parts = ", ".join(f"{_path_to_str(p)!r}: {v}" for p, v in self._entries)
288
+ return f"YearMinuteMap({{{parts}}})"
289
+
290
+
291
+ def _path_to_str(path: ParsedPath) -> str:
292
+ """ Canonical string reconstruction. """
293
+ if not path:
294
+ return "*"
295
+ parts = []
296
+ for tok in path:
297
+ lo, hi = tok.lo, tok.hi
298
+ rng = str(lo) if lo == hi else f"{lo}-{hi}"
299
+ if tok.kind == "QTR":
300
+ label = next(
301
+ (q for q, (ql, qh) in QTR_MONTHS.items() if (lo, hi) == (ql, qh)),
302
+ f"moy{rng}",
303
+ )
304
+ parts.append(label)
305
+ elif tok.kind == "MOY":
306
+ parts.append(f"moy{rng}")
307
+ elif tok.kind == "WOY":
308
+ parts.append(f"woy{rng}")
309
+ elif tok.kind == "DOY":
310
+ parts.append(f"doy{rng}")
311
+ elif tok.kind == "DOM":
312
+ parts.append(f"dom{rng}")
313
+ elif tok.kind == "DOW":
314
+ parts.append(f"dow{rng}")
315
+ elif tok.kind == "HH":
316
+ parts.append(f"h{rng}")
317
+ elif tok.kind == "MM":
318
+ parts.append(f"m{rng}")
319
+ return ".".join(parts)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ """ Demo."""
324
+ my_minute_map = {
325
+ "*": 8,
326
+ "h5-10": 28,
327
+ "h19": {
328
+ "*": 18,
329
+ "m30-59": 22,
330
+ },
331
+ "q2": {
332
+ "h0-4": 10,
333
+ "h5-10": 30,
334
+ "h11-18": 10,
335
+ "h19-23": 20,
336
+ },
337
+ "q3": {
338
+ "h0-4": 12,
339
+ "h5-10": 32,
340
+ "h11-18": 12,
341
+ "h19-23": 20,
342
+ "sun": {
343
+ "h0-4": 14,
344
+ "h5-10": 34,
345
+ "h11-18": 14,
346
+ "h19-23": 23,
347
+ },
348
+ },
349
+ }
350
+ ymm = YearMinuteMap(my_minute_map)
351
+ print(ymm)
352
+ print()
353
+ tests = [
354
+ (datetime(2024, 1, 15, 3, 0), 8, "winter night → *"),
355
+ (datetime(2024, 1, 15, 7, 0), 28, "winter morning h5-10"),
356
+ (datetime(2024, 1, 15, 19, 0), 18, "h19.*"),
357
+ (datetime(2024, 1, 15, 19, 45), 22, "h19.m30-59"),
358
+ (datetime(2024, 5, 1, 2, 0), 10, "q2.h0-4"),
359
+ (datetime(2024, 5, 1, 7, 0), 30, "q2.h5-10"),
360
+ (datetime(2024, 8, 4, 7, 0), 34, "q3.sun.h5-10"),
361
+ (datetime(2024, 8, 5, 7, 0), 32, "q3.mon.h5-10 (no sun override)"),
362
+ (datetime(2024, 8, 4, 20, 0), 23, "q3.sun.h19-23"),
363
+ ]
364
+ for dt, expected, label in tests:
365
+ got = ymm.get_value(dt)
366
+ matched = ymm.get_matching_spec(dt)
367
+ status = "✓" if got == expected else f"✗ (expected {expected})"
368
+ print(f" {status} {dt.strftime('%Y-%m-%d %H:%M')} → {got!s:3} ({matched}) # {label}")
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "minutemap"
7
+ version = "0.1.0"
8
+ description = "Provides a data type for mapping a value to every minute of a year and then resolve a value given a date"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ keywords = ["schedule", "time", "calendar", "home-assistant"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Home Automation",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/sgpinkue/minutemap"
28
+ Repository = "https://github.com/sgpinkus/minutemap"
29
+ Issues = "https://github.com/sgpinkus/minutemap/issues"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["minutemap"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,122 @@
1
+ """
2
+ Unit tests for YearMinuteMap.
3
+ """
4
+ import sys
5
+ from os.path import dirname, realpath
6
+ sys.path.append(dirname(realpath(__file__ + '/../../')))
7
+ sys.path.append(dirname(realpath(__file__ + '/../')))
8
+
9
+ import unittest
10
+ from datetime import datetime
11
+ from minutemap import YearMinuteMap
12
+
13
+
14
+ class TestYearMinuteMap(unittest.TestCase):
15
+
16
+ def test_valid(self):
17
+ cases = [
18
+ # (spec, datetime, expected_value, description)
19
+
20
+ # Wildcard fallback
21
+ ({"*": 7}, datetime(2024, 7, 4, 14, 0), 7, "bare wildcard"),
22
+
23
+ # Leaf wildcard is a no-op
24
+ ({"h19.*": 18}, datetime(2024, 1, 1, 19, 0), 18, "leaf wildcard stripped"),
25
+
26
+ # More specific spec wins over wildcard
27
+ ({"*": 1, "q1": 2}, datetime(2024, 2, 1, 0, 0), 2, "q1 beats wildcard"),
28
+
29
+ # Quarter aliases
30
+ ({"q2": 10}, datetime(2024, 4, 1, 0, 0), 10, "q2 matches April"),
31
+ ({"q2": 10}, datetime(2024, 6, 30, 23, 59), 10, "q2 matches June"),
32
+ ({"q2": 10}, datetime(2024, 7, 1, 0, 0), None, "q2 does not match July"),
33
+
34
+ # Month aliases
35
+ ({"apr": 30}, datetime(2024, 4, 10, 0, 0), 30, "apr alias"),
36
+ ({"dec": 12}, datetime(2024, 12, 25, 0, 0), 12, "dec alias"),
37
+
38
+ # DOW aliases
39
+ ({"mon": 1}, datetime(2024, 1, 1, 0, 0), 1, "mon alias (2024-01-01 is Monday)"),
40
+ ({"sun": 7}, datetime(2024, 1, 7, 0, 0), 7, "sun alias (2024-01-07 is Sunday)"),
41
+
42
+ # Ranges
43
+ ({"moy1-3": 55}, datetime(2024, 2, 15, 0, 0), 55, "moy range matches February"),
44
+ ({"moy1-3": 55}, datetime(2024, 4, 1, 0, 0), None, "moy range does not match April"),
45
+ ({"dow1-5": 10}, datetime(2024, 1, 1, 0, 0), 10, "dow1-5 matches Monday"),
46
+ ({"dow6-7": 20}, datetime(2024, 1, 6, 0, 0), 20, "dow6-7 matches Saturday"),
47
+ ({"h9-17": 5}, datetime(2024, 3, 1, 12, 0), 5, "h9-17 matches noon"),
48
+ ({"h9-17": 5}, datetime(2024, 3, 1, 18, 0), None, "h9-17 does not match 18:00"),
49
+
50
+ # Depth wins over breadth
51
+ ({"q1": 1, "q1.h9": 2}, datetime(2024, 1, 15, 9, 0), 2, "deeper path wins"),
52
+ ({"q1": 1, "q1.h9": 2}, datetime(2024, 1, 15, 10, 0), 1, "shallower path fallback"),
53
+
54
+ # Nested dict input
55
+ ({"h19": {"*": 18, "m30-59": 22}}, datetime(2024, 6, 1, 19, 0), 18, "nested: h19.*"),
56
+ ({"h19": {"*": 18, "m30-59": 22}}, datetime(2024, 6, 1, 19, 45), 22, "nested: h19.m30-59"),
57
+
58
+ # No match returns None
59
+ ({"q1": 1}, datetime(2024, 7, 4, 0, 0), None, "no match returns None"),
60
+
61
+ # Specificity
62
+ ({"*": 1, "q1": 2, "q1.dow7": 3, "q1.dow7.h9": 4, "q1.dow7.h9.m30": 5},
63
+ datetime(2024, 1, 7, 9, 30), 5, "specificity: full path wins over all shallower"),
64
+ ({"q1": 1, "jan": 2},
65
+ datetime(2024, 1, 15, 0, 0), 2, "specificity: MOY beats QTR at depth 1"),
66
+ ({"q1.dow1": 1, "q1.dom1": 2},
67
+ datetime(2024, 1, 1, 0, 0), 2, "specificity: DOM beats DOW at depth 2 (2024-01-01 is Monday)"),
68
+ ({"woy1": 1, "doy1": 2},
69
+ datetime(2024, 1, 1, 0, 0), 2, "specificity: DOY beats WOY at depth 1"),
70
+ ({"h9": {"*": 1, "m0": 2}},
71
+ datetime(2024, 6, 1, 9, 1), 1, "specificity: h9.* loses to h9.m0 for non-matching minute"),
72
+ ]
73
+ for spec, dt, expected, description in cases:
74
+ with self.subTest(description):
75
+ ymm = YearMinuteMap(spec)
76
+ self.assertEqual(ymm.get_value(dt), expected, description)
77
+
78
+ def test_invalid(self):
79
+ cases = [
80
+ # (spec_dict, expected_error_fragment, description)
81
+ ({"*": 7.7}, "expected int", "not an int"),
82
+ ({"h25": 1}, "out of bounds", "hour out of range"),
83
+ ({"m60": 1}, "out of bounds", "minute out of range"),
84
+ ({"moy13": 1}, "out of bounds", "month out of range"),
85
+ ({"dom32": 1}, "out of bounds", "day of month out of range"),
86
+ ({"dow8": 1}, "out of bounds", "day of week out of range"),
87
+ ({"doy367": 1}, "out of bounds", "day of year out of range"),
88
+ ({"woy54": 1}, "out of bounds", "week of year out of range"),
89
+ ({"moy6-3": 1}, "lo > hi", "range lo > hi"),
90
+ ({"moy2.woy5": 1}, "cannot follow", "woy cannot follow moy"),
91
+ ({"moy1.h5.dom3": 1}, "cannot follow", "dom cannot follow h"),
92
+ ({"h9.moy3": 1}, "cannot follow", "moy cannot follow h"),
93
+ ({"doy100.woy5": 1}, "cannot follow", "woy cannot follow doy"),
94
+ ({"*": "eight"}, "int", "non-integer value"),
95
+ ({"*": 3.14}, "int", "float value"),
96
+ ]
97
+ for spec, fragment, description in cases:
98
+ with self.subTest(description):
99
+ with self.assertRaises(ValueError) as ctx:
100
+ YearMinuteMap(spec)
101
+ self.assertIn(fragment, str(ctx.exception).lower(), description)
102
+
103
+ def test_valid_float_value(self):
104
+ cases = [({"*": 7.7}, datetime(2024, 7, 4, 14, 0), 7.7, "bare wildcard"),
105
+ ]
106
+ for spec, dt, expected, description in cases:
107
+ with self.subTest(description):
108
+ ymm = YearMinuteMap(spec, float)
109
+ self.assertEqual(ymm.get_value(dt), expected, description)
110
+
111
+ def test_invalid_float_value(self):
112
+ cases = [({"*": 7}, "expected float", "not a float"),
113
+ ]
114
+ for spec, fragment, description in cases:
115
+ with self.subTest(description):
116
+ with self.assertRaises(ValueError) as ctx:
117
+ YearMinuteMap(spec, float)
118
+ self.assertIn(fragment, str(ctx.exception).lower(), description),
119
+
120
+
121
+ if __name__ == "__main__":
122
+ unittest.main(verbosity=2)