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.
- minutemap-0.1.0/.github/workflows/publish.yml +115 -0
- minutemap-0.1.0/PKG-INFO +124 -0
- minutemap-0.1.0/README.md +103 -0
- minutemap-0.1.0/minutemap/__init__.py +4 -0
- minutemap-0.1.0/minutemap/main.py +368 -0
- minutemap-0.1.0/pyproject.toml +35 -0
- minutemap-0.1.0/tests/__init__.py +0 -0
- minutemap-0.1.0/tests/test_minutemap.py +122 -0
|
@@ -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/
|
minutemap-0.1.0/PKG-INFO
ADDED
|
@@ -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,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)
|