hassl 0.3.1__tar.gz → 0.3.2__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.
- {hassl-0.3.1/hassl.egg-info → hassl-0.3.2}/PKG-INFO +1 -1
- hassl-0.3.2/hassl/__init__.py +1 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/package.py +17 -2
- {hassl-0.3.1 → hassl-0.3.2}/hassl/parser/hassl.lark +9 -5
- {hassl-0.3.1 → hassl-0.3.2}/hassl/parser/transform.py +166 -341
- {hassl-0.3.1 → hassl-0.3.2}/hassl/semantics/analyzer.py +50 -4
- {hassl-0.3.1 → hassl-0.3.2/hassl.egg-info}/PKG-INFO +1 -1
- {hassl-0.3.1 → hassl-0.3.2}/pyproject.toml +1 -1
- {hassl-0.3.1 → hassl-0.3.2}/setup.cfg +1 -1
- hassl-0.3.1/hassl/__init__.py +0 -1
- {hassl-0.3.1 → hassl-0.3.2}/LICENSE +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/MANIFEST.in +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/README.md +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/ast/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/ast/nodes.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/cli.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/generate.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/init.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/rules_min.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/codegen/yaml_emit.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/parser/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/parser/loader.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/semantics/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl/semantics/domains.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl.egg-info/SOURCES.txt +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl.egg-info/dependency_links.txt +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl.egg-info/entry_points.txt +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl.egg-info/requires.txt +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/hassl.egg-info/top_level.txt +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/tests/test_codegen_sync_basic.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/tests/test_golden_ir_sync_shared.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/tests/test_imports_and_schedules.py +0 -0
- {hassl-0.3.1 → hassl-0.3.2}/tests/test_schedule_windows_codegen.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
|
@@ -215,7 +215,20 @@ def _holiday_condition(mode: Optional[str], hol_id: Optional[str]):
|
|
|
215
215
|
# True when today is a holiday for 'only', false when 'except'
|
|
216
216
|
eid = f"binary_sensor.hassl_holiday_{hol_id}"
|
|
217
217
|
return {"condition": "state", "entity_id": eid, "state": "on" if mode == "only" else "off"}
|
|
218
|
-
|
|
218
|
+
|
|
219
|
+
def _norm_hmode(raw: Optional[str]) -> Optional[str]:
|
|
220
|
+
"""Coerce analyzer-provided holiday_mode variants to {'only','except',None}."""
|
|
221
|
+
if not raw:
|
|
222
|
+
return None
|
|
223
|
+
v = str(raw).strip().lower().replace("_", " ").replace("-", " ")
|
|
224
|
+
# Accept a bunch of user/analyzer phrasings
|
|
225
|
+
if any(k in v for k in ("except", "exclude", "unless", "not")):
|
|
226
|
+
return "except"
|
|
227
|
+
if any(k in v for k in ("only", "holiday only", "holidays only")):
|
|
228
|
+
return "only"
|
|
229
|
+
# Unknown → leave as-is to avoid surprising behavior
|
|
230
|
+
return raw
|
|
231
|
+
|
|
219
232
|
def _trigger_for(ts: Dict[str, Any]) -> Dict[str, Any]:
|
|
220
233
|
"""
|
|
221
234
|
Build a HA trigger for a time-spec dict:
|
|
@@ -787,8 +800,10 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
787
800
|
|
|
788
801
|
ds = w.get("day_selector")
|
|
789
802
|
href = w.get("holiday_ref")
|
|
790
|
-
hmode = w.get("holiday_mode")
|
|
803
|
+
hmode = _norm_hmode(w.get("holiday_mode"))
|
|
791
804
|
period = w.get("period")
|
|
805
|
+
if href and hmode is None and ds in ("weekdays", "weekends"):
|
|
806
|
+
hmode = "except"
|
|
792
807
|
|
|
793
808
|
# Coerce time specs to dicts compatible with _trigger_for/_window_condition_from_specs
|
|
794
809
|
raw_start = w.get("start")
|
|
@@ -108,17 +108,18 @@ schedule_decl: PRIVATE? SCHEDULE CNAME ":" schedule_clause+
|
|
|
108
108
|
// Clauses usable both in declarations and inline
|
|
109
109
|
// Keep legacy form AND add new 'on …' forms
|
|
110
110
|
schedule_clause: schedule_legacy_clause
|
|
111
|
-
|
|
|
111
|
+
| schedule_window_clause
|
|
112
|
+
| sched_holiday_only
|
|
112
113
|
|
|
113
114
|
// Legacy (unchanged)
|
|
114
115
|
schedule_legacy_clause: schedule_op FROM time_spec schedule_end? ";"
|
|
115
116
|
|
|
116
117
|
// NEW schedule window syntax:
|
|
117
|
-
// [during <period>] on (
|
|
118
|
+
// [during <period>] on (weekdaysNo, |weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
|
|
118
119
|
// on holidays <id> HH:MM-HH:MM ;
|
|
119
|
-
|
|
120
|
-
| "on" "holidays" CNAME time_range ";"
|
|
120
|
+
schedule_window_clause: period? "on" day_selector time_range holiday_mod? ";"
|
|
121
121
|
|
|
122
|
+
sched_holiday_only: "on" "holidays" CNAME time_range ";"
|
|
122
123
|
// Periods
|
|
123
124
|
period: "during" "months" month_range
|
|
124
125
|
| "during" "dates" mmdd_range
|
|
@@ -131,7 +132,10 @@ MMDD: /\d{2}-\d{2}/
|
|
|
131
132
|
ymd_range: YMD ".." YMD
|
|
132
133
|
YMD: /\d{4}-\d{2}-\d{2}/
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
WEEKDAYS: "weekdays"
|
|
136
|
+
WEEKENDS: "weekends"
|
|
137
|
+
DAILY: "daily"
|
|
138
|
+
day_selector: WEEKDAYS | WEEKENDS | DAILY
|
|
135
139
|
time_range: TIME_HHMM "-" TIME_HHMM
|
|
136
140
|
holiday_mod: "except" "holidays" CNAME
|
|
137
141
|
|
|
@@ -7,7 +7,7 @@ def _atom(val):
|
|
|
7
7
|
s = str(val)
|
|
8
8
|
if t in ("INT",):
|
|
9
9
|
return int(s)
|
|
10
|
-
if t in ("SIGNED_NUMBER","NUMBER"):
|
|
10
|
+
if t in ("SIGNED_NUMBER", "NUMBER"):
|
|
11
11
|
try:
|
|
12
12
|
return int(s)
|
|
13
13
|
except ValueError:
|
|
@@ -18,6 +18,11 @@ def _atom(val):
|
|
|
18
18
|
return s[1:-1]
|
|
19
19
|
return val
|
|
20
20
|
|
|
21
|
+
def _flatten_entity_tree(val):
|
|
22
|
+
if isinstance(val, Tree) and getattr(val, "data", None) == "entity":
|
|
23
|
+
return ".".join(str(c) for c in val.children)
|
|
24
|
+
return val
|
|
25
|
+
|
|
21
26
|
def _to_str(x):
|
|
22
27
|
return str(x) if not isinstance(x, Token) else str(x)
|
|
23
28
|
|
|
@@ -28,101 +33,53 @@ class HasslTransformer(Transformer):
|
|
|
28
33
|
self.stmts = []
|
|
29
34
|
self.package = None
|
|
30
35
|
self.imports = []
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
# sticky token for day words if the parser inlines them oddly
|
|
37
|
+
self._last_day_token = None
|
|
38
|
+
|
|
39
|
+
# If your .lark declares these tokens (recommended), these hooks will fire:
|
|
40
|
+
def WEEKDAYS(self, t): self._last_day_token = "weekdays"; return "weekdays"
|
|
41
|
+
def WEEKENDS(self, t): self._last_day_token = "weekends"; return "weekends"
|
|
42
|
+
def DAILY(self, t): self._last_day_token = "daily"; return "daily"
|
|
43
|
+
|
|
44
|
+
# ============ Program root ============
|
|
33
45
|
def start(self, *stmts):
|
|
34
46
|
try:
|
|
35
|
-
return nodes.Program(statements=self.stmts, package=self.package,
|
|
36
|
-
imports=self.imports)
|
|
47
|
+
return nodes.Program(statements=self.stmts, package=self.package, imports=self.imports)
|
|
37
48
|
except TypeError:
|
|
38
49
|
return nodes.Program(statements=self.stmts)
|
|
39
50
|
|
|
40
|
-
#
|
|
41
|
-
def alias(self, *args):
|
|
42
|
-
private = False
|
|
43
|
-
if len(args) == 2:
|
|
44
|
-
name, entity = args
|
|
45
|
-
else:
|
|
46
|
-
priv_tok, name, entity = args
|
|
47
|
-
private = True if isinstance(priv_tok, Token) and priv_tok.type == "PRIVATE" else bool(priv_tok)
|
|
48
|
-
try:
|
|
49
|
-
a = nodes.Alias(name=str(name), entity=str(entity), private=private)
|
|
50
|
-
except TypeError:
|
|
51
|
-
a = nodes.Alias(name=str(name), entity=str(entity))
|
|
52
|
-
setattr(a, "private", private)
|
|
53
|
-
self.stmts.append(a)
|
|
54
|
-
return a
|
|
55
|
-
|
|
56
|
-
def sync(self, synctype, members, name, syncopts=None):
|
|
57
|
-
invert = []
|
|
58
|
-
if isinstance(syncopts, list):
|
|
59
|
-
invert = syncopts
|
|
60
|
-
s = nodes.Sync(kind=str(synctype), members=members, name=str(name), invert=invert)
|
|
61
|
-
self.stmts.append(s)
|
|
62
|
-
return s
|
|
63
|
-
|
|
64
|
-
def synctype(self, tok): return str(tok)
|
|
65
|
-
def syncopts(self, *args): return list(args)[-1] if args else []
|
|
66
|
-
def entity_list(self, *entities): return [str(e) for e in entities]
|
|
67
|
-
def member(self, val): return val
|
|
68
|
-
def entity(self, *parts): return ".".join(str(p) for p in parts)
|
|
69
|
-
|
|
70
|
-
# ================
|
|
71
|
-
# Package / Import
|
|
72
|
-
# ================
|
|
73
|
-
# package_decl: "package" entity
|
|
51
|
+
# ============ Package / Import ============
|
|
74
52
|
def package_decl(self, *children):
|
|
75
|
-
if not children:
|
|
76
|
-
|
|
77
|
-
dotted = children[-1] # handle optional literal "package"
|
|
53
|
+
if not children: raise ValueError("package_decl: missing children")
|
|
54
|
+
dotted = children[-1]
|
|
78
55
|
self.package = str(dotted)
|
|
79
56
|
self.stmts.append({"type": "package", "name": self.package})
|
|
80
57
|
return self.package
|
|
81
58
|
|
|
82
|
-
# ---- NEW: module_ref to support bare or dotted imports ----
|
|
83
|
-
# module_ref: CNAME ("." CNAME)*
|
|
84
59
|
def module_ref(self, *parts):
|
|
85
60
|
return ".".join(str(p) for p in parts)
|
|
86
61
|
|
|
87
|
-
# import_stmt: "import" module_ref import_tail?
|
|
88
62
|
def import_stmt(self, *children):
|
|
89
|
-
|
|
90
|
-
Accepts:
|
|
91
|
-
[module_ref] -> bare: import aliases
|
|
92
|
-
[module_ref, import_tail] -> import home.shared: x, y
|
|
93
|
-
["import", module_ref, ...] -> if the literal sneaks in
|
|
94
|
-
Normalizes to:
|
|
95
|
-
{"type":"import","module":<str>,"kind":<glob|list|alias|none>,
|
|
96
|
-
"items":[...], "as":<str|None>}
|
|
97
|
-
"""
|
|
98
|
-
if not children:
|
|
99
|
-
return None
|
|
100
|
-
|
|
101
|
-
# If the literal "import" is present, drop it.
|
|
63
|
+
if not children: return None
|
|
102
64
|
if isinstance(children[0], Token) and str(children[0]) == "import":
|
|
103
65
|
children = children[1:]
|
|
104
|
-
|
|
105
66
|
if len(children) == 1:
|
|
106
|
-
module = children[0]
|
|
107
|
-
tail = None
|
|
67
|
+
module, tail = children[0], None
|
|
108
68
|
elif len(children) == 2:
|
|
109
69
|
module, tail = children
|
|
110
70
|
else:
|
|
111
71
|
raise ValueError(f"import_stmt: unexpected children {children!r}")
|
|
112
72
|
|
|
113
|
-
# module_ref should already be a str (via module_ref()), but normalize just in case
|
|
114
73
|
if isinstance(module, Tree) and module.data == "module_ref":
|
|
115
74
|
module = ".".join(str(t.value) for t in module.children)
|
|
116
75
|
else:
|
|
117
76
|
module = str(module)
|
|
118
77
|
|
|
119
|
-
# Normalize tail
|
|
120
78
|
kind, items, as_name = ("none", [], None)
|
|
121
79
|
if tail is not None:
|
|
122
80
|
if isinstance(tail, tuple) and len(tail) == 3:
|
|
123
81
|
kind, items, as_name = tail
|
|
124
82
|
else:
|
|
125
|
-
# Defensive: try to parse tail-like shapes
|
|
126
83
|
norm = self.import_tail(tail)
|
|
127
84
|
if isinstance(norm, tuple) and len(norm) == 3:
|
|
128
85
|
kind, items, as_name = norm
|
|
@@ -132,64 +89,70 @@ class HasslTransformer(Transformer):
|
|
|
132
89
|
self.stmts.append({"type": "import", **imp})
|
|
133
90
|
return imp
|
|
134
91
|
|
|
135
|
-
# import_tail: ".*" | ":" import_list | "as" CNAME
|
|
136
|
-
# normalize to a tuple: (kind, items, as_name)
|
|
137
92
|
def import_tail(self, *args):
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# (Token('":"'), import_list_tree) -> list
|
|
141
|
-
# (Token('AS',"as"), Token('CNAME',...)) -> alias
|
|
142
|
-
if len(args) == 1 and isinstance(args[0], Token):
|
|
143
|
-
if str(args[0]) == ".*":
|
|
144
|
-
return ("glob", [], None)
|
|
145
|
-
|
|
93
|
+
if len(args) == 1 and isinstance(args[0], Token) and str(args[0]) == ".*":
|
|
94
|
+
return ("glob", [], None)
|
|
146
95
|
if len(args) == 2:
|
|
147
96
|
a0, a1 = args
|
|
148
|
-
# ":" import_list
|
|
149
97
|
if isinstance(a0, Token) and str(a0) == ":":
|
|
150
|
-
# a1 should already be a python list via import_list()
|
|
151
98
|
return ("list", a1 if isinstance(a1, list) else [a1], None)
|
|
152
|
-
# "as" CNAME (either literal or tokenized)
|
|
153
99
|
if (isinstance(a0, Token) and str(a0) == "as") or (isinstance(a0, str) and a0 == "as"):
|
|
154
100
|
return ("alias", [], str(a1))
|
|
155
|
-
|
|
156
|
-
# Already normalized (kind, items, as_name)
|
|
157
101
|
if len(args) == 3 and isinstance(args[0], str):
|
|
158
|
-
return args
|
|
159
|
-
|
|
160
|
-
# Optional tail missing or unknown -> "none"
|
|
102
|
+
return args
|
|
161
103
|
return ("none", [], None)
|
|
162
104
|
|
|
163
105
|
def import_list(self, *items): return list(items)
|
|
164
106
|
|
|
165
|
-
# import_item: CNAME ("as" CNAME)?
|
|
166
107
|
def import_item(self, *parts):
|
|
167
108
|
if len(parts) == 1:
|
|
168
109
|
return {"name": str(parts[0]), "as": None}
|
|
169
110
|
return {"name": str(parts[0]), "as": str(parts[-1])}
|
|
170
111
|
|
|
171
|
-
#
|
|
112
|
+
# ============ Aliases / Sync ============
|
|
113
|
+
def alias(self, *args):
|
|
114
|
+
private = False
|
|
115
|
+
if len(args) == 2:
|
|
116
|
+
name, entity = args
|
|
117
|
+
else:
|
|
118
|
+
priv_tok, name, entity = args
|
|
119
|
+
private = (isinstance(priv_tok, Token) and priv_tok.type == "PRIVATE") or bool(priv_tok)
|
|
120
|
+
entity = _flatten_entity_tree(entity)
|
|
121
|
+
a = nodes.Alias(name=str(name), entity=str(entity), private=private)
|
|
122
|
+
self.stmts.append(a)
|
|
123
|
+
return a
|
|
124
|
+
|
|
125
|
+
def sync(self, synctype, members, name, syncopts=None):
|
|
126
|
+
invert = syncopts if isinstance(syncopts, list) else []
|
|
127
|
+
s = nodes.Sync(kind=str(synctype), members=members, name=str(name), invert=invert)
|
|
128
|
+
self.stmts.append(s)
|
|
129
|
+
return s
|
|
130
|
+
|
|
131
|
+
def synctype(self, tok): return str(tok)
|
|
132
|
+
def syncopts(self, *args): return list(args)[-1] if args else []
|
|
133
|
+
def entity_list(self, *entities): return [str(e) for e in entities]
|
|
134
|
+
def member(self, val): return val
|
|
135
|
+
|
|
136
|
+
def entity(self, *parts): return ".".join(str(p) for p in parts)
|
|
137
|
+
|
|
138
|
+
# ============ Rules ============
|
|
172
139
|
def rule(self, name, *clauses):
|
|
173
140
|
r = nodes.Rule(name=str(name), clauses=list(clauses))
|
|
174
141
|
self.stmts.append(r)
|
|
175
142
|
return r
|
|
176
143
|
|
|
177
|
-
# if_clause: "if" "(" expr qualifier? ")" qualifier? "then" actions
|
|
178
144
|
def if_clause(self, *parts):
|
|
179
145
|
actions = parts[-1]
|
|
180
146
|
core = list(parts[:-1])
|
|
181
147
|
expr = core[0]
|
|
182
148
|
quals = [q for q in core[1:] if isinstance(q, dict) and "not_by" in q]
|
|
183
149
|
cond = {"expr": expr}
|
|
184
|
-
if quals:
|
|
185
|
-
cond.update(quals[-1]) # prefer last qualifier
|
|
150
|
+
if quals: cond.update(quals[-1])
|
|
186
151
|
return nodes.IfClause(condition=cond, actions=actions)
|
|
187
152
|
|
|
188
|
-
# --- Condition & boolean ops ---
|
|
189
153
|
def condition(self, expr, qual=None):
|
|
190
154
|
cond = {"expr": expr}
|
|
191
|
-
if qual is not None:
|
|
192
|
-
cond.update(qual)
|
|
155
|
+
if qual is not None: cond.update(qual)
|
|
193
156
|
return cond
|
|
194
157
|
|
|
195
158
|
def qualifier(self, *args):
|
|
@@ -205,25 +168,21 @@ class HasslTransformer(Transformer):
|
|
|
205
168
|
def not_(self, term): return {"op": "not", "value": term}
|
|
206
169
|
|
|
207
170
|
def comparison(self, left, op=None, right=None):
|
|
208
|
-
if op is None:
|
|
209
|
-
return left
|
|
171
|
+
if op is None: return left
|
|
210
172
|
return {"op": str(op), "left": left, "right": right}
|
|
211
173
|
|
|
212
174
|
def bare_operand(self, val): return _atom(val)
|
|
213
175
|
def operand(self, val): return _atom(val)
|
|
214
176
|
def OP(self, tok): return str(tok)
|
|
215
177
|
|
|
216
|
-
# --- Actions ---
|
|
217
178
|
def actions(self, *acts): return list(acts)
|
|
218
179
|
def action(self, act): return act
|
|
219
180
|
|
|
220
|
-
def dur(self, n, unit):
|
|
221
|
-
return f"{int(str(n))}{str(unit)}"
|
|
181
|
+
def dur(self, n, unit): return f"{int(str(n))}{str(unit)}"
|
|
222
182
|
|
|
223
183
|
def assign(self, name, state, *for_parts):
|
|
224
184
|
act = {"type": "assign", "target": str(name), "state": str(state)}
|
|
225
|
-
if for_parts:
|
|
226
|
-
act["for"] = for_parts[0]
|
|
185
|
+
if for_parts: act["for"] = for_parts[0]
|
|
227
186
|
return act
|
|
228
187
|
|
|
229
188
|
def attr_assign(self, *parts):
|
|
@@ -236,16 +195,11 @@ class HasslTransformer(Transformer):
|
|
|
236
195
|
def waitact(self, cond, dur, action):
|
|
237
196
|
return {"type": "wait", "condition": cond, "for": dur, "then": action}
|
|
238
197
|
|
|
239
|
-
# Robust rule control
|
|
240
198
|
def rulectrl(self, *parts):
|
|
241
|
-
from lark import Token
|
|
242
199
|
def s(x): return str(x) if isinstance(x, Token) else x
|
|
243
200
|
vals = [s(p) for p in parts]
|
|
244
|
-
|
|
245
201
|
op = next((v.lower() for v in vals if isinstance(v, str) and v.lower() in ("disable","enable")), "disable")
|
|
246
|
-
|
|
247
|
-
name = None
|
|
248
|
-
keywords = {"rule", "for", "until", "disable", "enable"}
|
|
202
|
+
name = None; keywords = {"rule", "for", "until", "disable", "enable"}
|
|
249
203
|
if "rule" in [str(v).lower() for v in vals if isinstance(v, str)]:
|
|
250
204
|
for i, v in enumerate(vals):
|
|
251
205
|
if isinstance(v, str) and v.lower() == "rule" and i + 1 < len(vals):
|
|
@@ -256,40 +210,26 @@ class HasslTransformer(Transformer):
|
|
|
256
210
|
name = v; break
|
|
257
211
|
if name is None:
|
|
258
212
|
raise ValueError(f"rulectrl: could not determine rule name from parts={vals!r}")
|
|
259
|
-
|
|
260
213
|
payload = {}
|
|
261
|
-
try:
|
|
262
|
-
|
|
263
|
-
except ValueError:
|
|
264
|
-
start_idx = 1
|
|
265
|
-
|
|
214
|
+
try: start_idx = vals.index(name) + 1
|
|
215
|
+
except ValueError: start_idx = 1
|
|
266
216
|
i = start_idx
|
|
267
217
|
while i < len(vals):
|
|
268
218
|
v = vals[i]; vlow = str(v).lower() if isinstance(v, str) else ""
|
|
269
|
-
if vlow == "for" and i + 1 < len(vals):
|
|
270
|
-
|
|
271
|
-
if vlow == "until" and i + 1 < len(vals):
|
|
272
|
-
payload["until"] = vals[i + 1]; i += 2; continue
|
|
219
|
+
if vlow == "for" and i + 1 < len(vals): payload["for"] = vals[i + 1]; i += 2; continue
|
|
220
|
+
if vlow == "until" and i + 1 < len(vals): payload["until"] = vals[i + 1]; i += 2; continue
|
|
273
221
|
i += 1
|
|
274
|
-
|
|
275
222
|
if not payload:
|
|
276
223
|
for v in vals[start_idx:]:
|
|
277
224
|
if isinstance(v, str) and any(v.endswith(u) for u in ("ms","s","m","h","d")):
|
|
278
225
|
payload["for"] = v; break
|
|
279
|
-
|
|
280
|
-
if not payload:
|
|
281
|
-
payload["for"] = "0s"
|
|
282
|
-
|
|
226
|
+
if not payload: payload["for"] = "0s"
|
|
283
227
|
return {"type": "rule_ctrl", "op": op, "rule": str(name), **payload}
|
|
284
228
|
|
|
285
229
|
def tagact(self, name, val):
|
|
286
230
|
return {"type": "tag", "name": str(name), "value": _atom(val)}
|
|
287
231
|
|
|
288
|
-
#
|
|
289
|
-
# Schedules (composable)
|
|
290
|
-
# ======================
|
|
291
|
-
|
|
292
|
-
# schedule_decl: PRIVATE? SCHEDULE CNAME ":" schedule_clause+
|
|
232
|
+
# ============ Schedules ============
|
|
293
233
|
def schedule_decl(self, *parts):
|
|
294
234
|
idx = 0
|
|
295
235
|
private = False
|
|
@@ -302,48 +242,32 @@ class HasslTransformer(Transformer):
|
|
|
302
242
|
name = str(parts[idx]); idx += 1
|
|
303
243
|
if idx < len(parts) and isinstance(parts[idx], Token) and str(parts[idx]) == ":":
|
|
304
244
|
idx += 1
|
|
305
|
-
# Legacy clauses (enable/disable from ... to/until ...)
|
|
306
245
|
clauses = [c for c in parts[idx:] if isinstance(c, dict) and c.get("type") == "schedule_clause"]
|
|
307
|
-
# New-form windows (nodes.ScheduleWindow)
|
|
308
246
|
windows = [w for w in parts[idx:] if isinstance(w, nodes.ScheduleWindow)]
|
|
309
247
|
sched = nodes.Schedule(name=name, clauses=clauses, windows=windows, private=private)
|
|
310
248
|
self.stmts.append(sched)
|
|
311
249
|
return sched
|
|
312
|
-
|
|
313
|
-
# rule_schedule_use: SCHEDULE USE name_list ";"
|
|
250
|
+
|
|
314
251
|
def rule_schedule_use(self, *args):
|
|
315
|
-
# Accept both strict and loose forms: (SCHEDULE, USE, name_list, ';') or just (name_list)
|
|
316
252
|
names = None
|
|
317
253
|
for a in args:
|
|
318
|
-
if isinstance(a, list):
|
|
319
|
-
names = a
|
|
254
|
+
if isinstance(a, list): names = a
|
|
320
255
|
if names is None:
|
|
321
256
|
names = [str(a) for a in args if isinstance(a, (str, Token))]
|
|
322
257
|
norm = [n if isinstance(n, str) else str(n) for n in names]
|
|
323
258
|
return {"type": "schedule_use", "names": norm}
|
|
324
259
|
|
|
325
|
-
# rule_schedule_inline: SCHEDULE schedule_clause+
|
|
326
260
|
def rule_schedule_inline(self, *parts):
|
|
327
|
-
# Parts may include the literal 'schedule' token; filter and keep only clause dicts.
|
|
328
261
|
clauses = [p for p in parts if isinstance(p, dict) and p.get("type") == "schedule_clause"]
|
|
329
262
|
return {"type": "schedule_inline", "clauses": clauses}
|
|
330
263
|
|
|
331
|
-
# schedule_clause is now an alternation:
|
|
332
|
-
# schedule_clause: schedule_legacy_clause | schedule_new_clause
|
|
333
|
-
# Lark passes the single child. Just forward it.
|
|
334
264
|
def schedule_clause(self, item=None, *rest):
|
|
335
|
-
|
|
336
|
-
if isinstance(item, dict):
|
|
337
|
-
return item
|
|
265
|
+
if isinstance(item, dict): return item
|
|
338
266
|
for r in rest:
|
|
339
|
-
if isinstance(r, dict):
|
|
340
|
-
return r
|
|
267
|
+
if isinstance(r, dict): return r
|
|
341
268
|
return item
|
|
342
269
|
|
|
343
|
-
# Legacy shape stays the same; build the dict here.
|
|
344
|
-
# schedule_legacy_clause: schedule_op FROM time_spec schedule_end? ";"
|
|
345
270
|
def schedule_legacy_clause(self, *args):
|
|
346
|
-
# Expect: op, 'from', start, [end], [';']
|
|
347
271
|
op = "enable"; start = None; end = None
|
|
348
272
|
for a in args:
|
|
349
273
|
if isinstance(a, Token) and a.type in ("ENABLE","DISABLE"):
|
|
@@ -352,57 +276,57 @@ class HasslTransformer(Transformer):
|
|
|
352
276
|
if start is None: start = a
|
|
353
277
|
else: end = a if isinstance(a, dict) else end
|
|
354
278
|
elif isinstance(a, dict) and ("to" in a or "until" in a):
|
|
355
|
-
# already normalized end-shape
|
|
356
279
|
end = a.get("to") or a.get("until")
|
|
357
280
|
d = {"type": "schedule_clause", "op": op, "from": start}
|
|
358
|
-
if end is not None:
|
|
359
|
-
# keep legacy downstream compatibility
|
|
360
|
-
d.update({"to": end})
|
|
281
|
+
if end is not None: d.update({"to": end})
|
|
361
282
|
return d
|
|
362
283
|
|
|
363
|
-
def schedule_op(self, tok):
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def schedule_until(self, _until_kw, ts):
|
|
370
|
-
return {"until": ts}
|
|
371
|
-
|
|
372
|
-
def name_list(self, *names):
|
|
373
|
-
return [n if isinstance(n, str) else str(n) for n in names]
|
|
374
|
-
|
|
375
|
-
def name(self, val):
|
|
376
|
-
return str(val)
|
|
377
|
-
|
|
378
|
-
def time_clock(self, tok):
|
|
379
|
-
return {"kind": "clock", "value": str(tok)}
|
|
284
|
+
def schedule_op(self, tok): return str(tok).lower()
|
|
285
|
+
def schedule_to(self, _to_kw, ts): return {"to": ts}
|
|
286
|
+
def schedule_until(self, _until_kw, ts): return {"until": ts}
|
|
287
|
+
def name_list(self, *names): return [n if isinstance(n, str) else str(n) for n in names]
|
|
288
|
+
def name(self, val): return str(val)
|
|
380
289
|
|
|
290
|
+
def time_clock(self, tok): return {"kind": "clock", "value": str(tok)}
|
|
381
291
|
def time_sun(self, event_tok, offset_tok=None):
|
|
382
292
|
event = str(event_tok).lower()
|
|
383
293
|
off = str(offset_tok) if offset_tok is not None else "0s"
|
|
384
294
|
return {"kind": "sun", "event": event, "offset": off}
|
|
385
295
|
|
|
386
|
-
def time_spec(self, *children):
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def rule_clause(self, item):
|
|
390
|
-
return item
|
|
296
|
+
def time_spec(self, *children): return children[0] if children else None
|
|
297
|
+
def rule_clause(self, item): return item
|
|
391
298
|
|
|
392
|
-
|
|
393
|
-
# NEW: Windows & Periods
|
|
394
|
-
# ======================
|
|
395
|
-
# schedule_new_clause:
|
|
396
|
-
# period? "on" day_selector time_range holiday_mod? ";"
|
|
397
|
-
# | "on" "holidays" CNAME time_range ";"
|
|
398
|
-
def schedule_new_clause(self, *parts):
|
|
299
|
+
def sched_holiday_only(self, *args):
|
|
399
300
|
"""
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
on holidays ID HH:MM-HH:MM ;
|
|
403
|
-
Accepts either ("time", start, end) or {"start","end"} from time_range().
|
|
301
|
+
Handles: on holidays <CNAME> HH:MM-HH:MM ;
|
|
302
|
+
Args arrive as ( 'on', 'holidays', <CNAME token>, time_range_tuple, ';' )
|
|
404
303
|
"""
|
|
405
304
|
from lark import Token
|
|
305
|
+
|
|
306
|
+
ident = None
|
|
307
|
+
start = None
|
|
308
|
+
end = None
|
|
309
|
+
|
|
310
|
+
for a in args:
|
|
311
|
+
if isinstance(a, Token) and a.type == "CNAME":
|
|
312
|
+
ident = str(a)
|
|
313
|
+
elif isinstance(a, tuple) and a and a[0] == "time":
|
|
314
|
+
# ("time", "HH:MM", "HH:MM")
|
|
315
|
+
start, end = a[1], a[2]
|
|
316
|
+
|
|
317
|
+
return nodes.ScheduleWindow(
|
|
318
|
+
start=str(start) if start is not None else "00:00",
|
|
319
|
+
end=str(end) if end is not None else "00:00",
|
|
320
|
+
day_selector="daily",
|
|
321
|
+
period=None,
|
|
322
|
+
holiday_ref=str(ident) if ident is not None else "",
|
|
323
|
+
holiday_mode="only",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# -------- New windows & periods --------
|
|
327
|
+
def schedule_window_clause(self, *parts):
|
|
328
|
+
# Reset sticky day for each clause
|
|
329
|
+
self._last_day_token = None
|
|
406
330
|
|
|
407
331
|
psel = None
|
|
408
332
|
day = None
|
|
@@ -410,91 +334,65 @@ class HasslTransformer(Transformer):
|
|
|
410
334
|
end = None
|
|
411
335
|
holiday_mode = None
|
|
412
336
|
holiday_ref = None
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
prev_except = False # just saw the literal 'except'
|
|
337
|
+
prev_holidays = False
|
|
338
|
+
prev_except = False
|
|
416
339
|
|
|
417
340
|
for p in parts:
|
|
418
341
|
if p is None:
|
|
419
342
|
continue
|
|
420
343
|
|
|
421
|
-
# period selector node
|
|
422
344
|
if isinstance(p, nodes.PeriodSelector):
|
|
423
345
|
psel = p
|
|
424
|
-
prev_holidays = False
|
|
425
|
-
prev_except = False
|
|
426
346
|
continue
|
|
427
347
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
348
|
+
if isinstance(p, str) and p in ("weekdays", "weekends", "daily"):
|
|
349
|
+
day = p; continue
|
|
350
|
+
|
|
351
|
+
if isinstance(p, Tree) and getattr(p, "data", None) == "day_selector":
|
|
352
|
+
if p.children:
|
|
353
|
+
val = str(p.children[0]).lower()
|
|
354
|
+
if val in ("weekdays", "weekends", "daily"): day = val
|
|
433
355
|
continue
|
|
356
|
+
|
|
357
|
+
if isinstance(p, tuple) and p and p[0] == "time":
|
|
358
|
+
start, end = p[1], p[2]; continue
|
|
434
359
|
if isinstance(p, dict) and "start" in p and "end" in p:
|
|
435
|
-
start, end = p["start"], p["end"]
|
|
436
|
-
prev_holidays = False
|
|
437
|
-
prev_except = False
|
|
438
|
-
continue
|
|
360
|
+
start, end = p["start"], p["end"]; continue
|
|
439
361
|
|
|
440
|
-
# holiday modifier from holiday_mod()
|
|
441
362
|
if isinstance(p, tuple) and p and p[0] == "holiday_mod":
|
|
442
363
|
holiday_mode, holiday_ref = p[1], p[2]
|
|
443
|
-
prev_holidays = False
|
|
444
|
-
prev_except = False
|
|
364
|
+
prev_holidays = False; prev_except = False
|
|
445
365
|
continue
|
|
446
366
|
|
|
447
|
-
# tokens / strings
|
|
448
367
|
if isinstance(p, Token):
|
|
449
368
|
sval = str(p).lower()
|
|
450
|
-
if sval == "except":
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
continue
|
|
454
|
-
if sval == "holidays":
|
|
455
|
-
prev_holidays = True
|
|
456
|
-
continue
|
|
457
|
-
if sval in ("weekdays", "weekends", "daily"):
|
|
458
|
-
day = sval
|
|
459
|
-
prev_holidays = False
|
|
460
|
-
prev_except = False
|
|
461
|
-
continue
|
|
369
|
+
if sval == "except": prev_except = True; continue
|
|
370
|
+
if sval in ("holiday", "holidays"): prev_holidays = True; continue
|
|
371
|
+
if sval in ("weekdays", "weekends", "daily"): day = sval; continue
|
|
462
372
|
if p.type == "CNAME" and prev_holidays and holiday_ref is None:
|
|
463
373
|
holiday_ref = str(p)
|
|
464
|
-
holiday_mode =
|
|
465
|
-
prev_holidays = False
|
|
466
|
-
prev_except = False
|
|
374
|
+
holiday_mode = "except" if prev_except else "only"
|
|
375
|
+
prev_holidays = False; prev_except = False
|
|
467
376
|
continue
|
|
468
377
|
|
|
469
378
|
if isinstance(p, str):
|
|
470
379
|
sval = p.lower()
|
|
471
|
-
if sval == "except":
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
continue
|
|
475
|
-
if sval == "holidays":
|
|
476
|
-
prev_holidays = True
|
|
477
|
-
continue
|
|
478
|
-
if sval in ("weekdays", "weekends", "daily"):
|
|
479
|
-
day = sval
|
|
480
|
-
prev_holidays = False
|
|
481
|
-
prev_except = False
|
|
482
|
-
continue
|
|
483
|
-
if prev_holidays and holiday_ref is None and sval not in ("on", "holidays", ";", ":"):
|
|
380
|
+
if sval == "except": prev_except = True; continue
|
|
381
|
+
if sval in ("holiday", "holidays"): prev_holidays = True; continue
|
|
382
|
+
if prev_holidays and holiday_ref is None and sval not in ("on","holidays",";",";"):
|
|
484
383
|
holiday_ref = p
|
|
485
|
-
holiday_mode =
|
|
486
|
-
prev_holidays = False
|
|
487
|
-
prev_except = False
|
|
384
|
+
holiday_mode = "except" if prev_except else "only"
|
|
385
|
+
prev_holidays = False; prev_except = False
|
|
488
386
|
continue
|
|
489
387
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
prev_except = False
|
|
493
|
-
|
|
494
|
-
# default day when omitted (holiday-only branch or defensive)
|
|
388
|
+
if day is None and self._last_day_token:
|
|
389
|
+
day = self._last_day_token
|
|
495
390
|
if day is None:
|
|
496
391
|
day = "daily"
|
|
497
392
|
|
|
393
|
+
if day in ("weekdays", "weekends") and holiday_ref and not holiday_mode:
|
|
394
|
+
holiday_mode = "except"
|
|
395
|
+
|
|
498
396
|
return nodes.ScheduleWindow(
|
|
499
397
|
start=str(start) if start is not None else "00:00",
|
|
500
398
|
end=str(end) if end is not None else "00:00",
|
|
@@ -504,41 +402,28 @@ class HasslTransformer(Transformer):
|
|
|
504
402
|
holiday_mode=holiday_mode
|
|
505
403
|
)
|
|
506
404
|
|
|
507
|
-
# Dedicated handler if your parser surfaces the holiday-only branch separately:
|
|
508
405
|
def sched_holiday_only(self, *args):
|
|
509
|
-
# Accept: 'on','holidays', ident, time_range, [';']
|
|
510
406
|
ident = None; start=None; end=None
|
|
511
407
|
for a in args:
|
|
512
|
-
if isinstance(a, Token) and a.type == "CNAME":
|
|
513
|
-
|
|
514
|
-
elif isinstance(a,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
return nodes.ScheduleWindow(
|
|
521
|
-
start=str(start), end=str(end),
|
|
522
|
-
day_selector="daily",
|
|
523
|
-
period=None,
|
|
524
|
-
holiday_ref=str(ident) if ident is not None else "",
|
|
525
|
-
holiday_mode="only"
|
|
526
|
-
)
|
|
408
|
+
if isinstance(a, Token) and a.type == "CNAME": ident = str(a)
|
|
409
|
+
elif isinstance(a, tuple) and a and a[0] == "time": start, end = a[1], a[2]
|
|
410
|
+
elif isinstance(a, dict) and "start" in a and "end" in a: start, end = a["start"], a["end"]
|
|
411
|
+
elif isinstance(a, str) and a not in ("on","holidays",";"): ident = a
|
|
412
|
+
return nodes.ScheduleWindow(start=str(start), end=str(end),
|
|
413
|
+
day_selector="daily", period=None,
|
|
414
|
+
holiday_ref=str(ident) if ident is not None else "",
|
|
415
|
+
holiday_mode="only")
|
|
527
416
|
|
|
528
417
|
def period(self, *args):
|
|
529
|
-
# Transparent wrapper around PeriodSelector
|
|
530
418
|
for a in args:
|
|
531
|
-
if isinstance(a, nodes.PeriodSelector):
|
|
532
|
-
return a
|
|
419
|
+
if isinstance(a, nodes.PeriodSelector): return a
|
|
533
420
|
return args[0] if args else None
|
|
534
421
|
|
|
535
|
-
# month_range: MONTH (".." MONTH)? ("," MONTH)*
|
|
536
422
|
def month_range(self, *parts):
|
|
537
423
|
items = [str(x) for x in parts if not (isinstance(x, Token) and str(x) == "..")]
|
|
538
424
|
dots = any(isinstance(x, Token) and str(x) == ".." for x in parts)
|
|
539
425
|
if dots:
|
|
540
|
-
if len(items) < 2:
|
|
541
|
-
raise ValueError("month_range: expected A .. B")
|
|
426
|
+
if len(items) < 2: raise ValueError("month_range: expected A .. B")
|
|
542
427
|
return nodes.PeriodSelector(kind="months", data={"range": [items[0], items[1]]})
|
|
543
428
|
return nodes.PeriodSelector(kind="months", data={"list": items})
|
|
544
429
|
|
|
@@ -555,105 +440,60 @@ class HasslTransformer(Transformer):
|
|
|
555
440
|
return nodes.PeriodSelector(kind="range", data={"start": a, "end": b})
|
|
556
441
|
|
|
557
442
|
def day_selector(self, *args):
|
|
558
|
-
# Accept either a single token (normal) or no children (defensive).
|
|
559
|
-
# When empty, default to "daily" so schedule_new_clause can still build.
|
|
560
443
|
if not args:
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
444
|
+
val = self._last_day_token
|
|
445
|
+
self._last_day_token = None
|
|
446
|
+
return val or "daily"
|
|
447
|
+
tok = str(args[0]).lower()
|
|
448
|
+
if tok in ("weekday", "wd", "mon-fri", "monfri"): return "weekdays"
|
|
449
|
+
if tok in ("weekend", "we", "sat-sun", "satsun"): return "weekends"
|
|
450
|
+
if tok in ("weekdays", "weekends", "daily"): return tok
|
|
451
|
+
return tok
|
|
564
452
|
|
|
565
453
|
def time_range(self, *args):
|
|
566
454
|
from lark import Token
|
|
567
|
-
# strip literal '-' if present
|
|
568
455
|
parts = [a for a in args if not (isinstance(a, Token) and str(a) == "-")]
|
|
569
|
-
# Case 1: two TIME_HHMM tokens
|
|
570
456
|
times = [str(p) for p in parts if isinstance(p, Token) and p.type == "TIME_HHMM"]
|
|
571
|
-
if len(times) >= 2:
|
|
572
|
-
return ("time", times[0], times[1])
|
|
573
|
-
# Case 2: two plain strings
|
|
457
|
+
if len(times) >= 2: return ("time", times[0], times[1])
|
|
574
458
|
s_parts = [str(p) for p in parts if not isinstance(p, Token)]
|
|
575
459
|
if len(s_parts) >= 2 and ":" in s_parts[0] and ":" in s_parts[1]:
|
|
576
460
|
return ("time", s_parts[0], s_parts[1])
|
|
577
|
-
# Case 3: single "HH:MM-HH:MM"
|
|
578
461
|
if len(parts) == 1 and isinstance(parts[0], (str, Token)):
|
|
579
462
|
val = str(parts[0])
|
|
580
463
|
if "-" in val and ":" in val:
|
|
581
464
|
a, b = val.split("-", 1)
|
|
582
465
|
return ("time", a.strip(), b.strip())
|
|
583
|
-
# Fallback (shouldn't happen): return a harmless range
|
|
584
466
|
return ("time", "00:00", "00:00")
|
|
585
467
|
|
|
586
468
|
def holiday_mod(self, *args):
|
|
587
|
-
""
|
|
588
|
-
Accepts shapes like:
|
|
589
|
-
- 'except' 'holidays' CNAME
|
|
590
|
-
- 'on' 'holidays' CNAME (treated as 'only')
|
|
591
|
-
- 'holidays' CNAME (default to 'only')
|
|
592
|
-
Returns a tuple normalized for schedule_new_clause: ('holiday_mod', mode, ident)
|
|
593
|
-
"""
|
|
594
|
-
mode = "only"
|
|
595
|
-
ident = None
|
|
469
|
+
mode = "only"; ident = None
|
|
596
470
|
for a in args:
|
|
597
471
|
s = str(a).lower()
|
|
598
|
-
if s == "except":
|
|
599
|
-
|
|
600
|
-
elif s in ("on","only"):
|
|
601
|
-
mode = "only"
|
|
472
|
+
if s == "except": mode = "except"
|
|
473
|
+
elif s in ("on","only"): mode = "only"
|
|
602
474
|
if isinstance(a, Token) and a.type == "CNAME":
|
|
603
475
|
ident = str(a)
|
|
604
|
-
elif isinstance(a, str) and s not in ("holidays","except","on","only",":",";"):
|
|
476
|
+
elif isinstance(a, str) and s not in ("holiday","holidays","except","on","only",":",";"):
|
|
605
477
|
ident = str(a)
|
|
606
478
|
return ("holiday_mod", mode, ident or "")
|
|
607
479
|
|
|
608
|
-
#
|
|
609
|
-
def MONTH(self, t): return str(t)
|
|
610
|
-
def MMDD(self, t): return str(t)
|
|
611
|
-
def YMD(self, t): return str(t)
|
|
612
|
-
|
|
613
|
-
# =====================
|
|
614
|
-
# NEW: Holidays decl(s)
|
|
615
|
-
# =====================
|
|
616
|
-
# holidays_decl: "holidays" CNAME ":" holi_kv ("," holi_kv)*
|
|
480
|
+
# ============ Holidays declaration ============
|
|
617
481
|
def holidays_decl(self, *children):
|
|
618
|
-
|
|
619
|
-
Accepts children like: 'holidays', CNAME, ':', (kv, ',', kv, ...).
|
|
620
|
-
Robustly extracts the first CNAME as the id and all ('key', value) tuples.
|
|
621
|
-
"""
|
|
622
|
-
from lark import Token, Tree
|
|
623
|
-
|
|
624
|
-
ident = None
|
|
625
|
-
kvs = []
|
|
626
|
-
|
|
482
|
+
ident = None; kvs = []
|
|
627
483
|
for ch in children:
|
|
628
|
-
# id
|
|
629
484
|
if ident is None:
|
|
630
485
|
if isinstance(ch, Token) and ch.type == "CNAME":
|
|
631
|
-
ident = str(ch)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
ident = ch
|
|
635
|
-
continue
|
|
636
|
-
# kvs arrive as tuples from helper methods
|
|
486
|
+
ident = str(ch); continue
|
|
487
|
+
if isinstance(ch, str):
|
|
488
|
+
ident = ch; continue
|
|
637
489
|
if isinstance(ch, tuple) and len(ch) == 2 and isinstance(ch[0], str):
|
|
638
490
|
kvs.append(ch)
|
|
639
491
|
|
|
640
|
-
|
|
641
|
-
params =
|
|
642
|
-
"country": None,
|
|
643
|
-
"province": None,
|
|
644
|
-
"add": [],
|
|
645
|
-
"remove": [],
|
|
646
|
-
"workdays": None,
|
|
647
|
-
"excludes": None,
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
for k, v in kvs:
|
|
651
|
-
params[k] = v
|
|
492
|
+
params = {"country": None, "province": None, "add": [], "remove": [], "workdays": None, "excludes": None}
|
|
493
|
+
for k, v in kvs: params[k] = v
|
|
652
494
|
|
|
653
|
-
# normalize quoted strings (country/province/add/remove can be quoted)
|
|
654
495
|
def unq(s):
|
|
655
|
-
if isinstance(s, str) and len(s) >= 2 and s[0] == s[-1] == '"':
|
|
656
|
-
return s[1:-1]
|
|
496
|
+
if isinstance(s, str) and len(s) >= 2 and s[0] == s[-1] == '"': return s[1:-1]
|
|
657
497
|
return s
|
|
658
498
|
|
|
659
499
|
country = unq(params["country"])
|
|
@@ -663,31 +503,16 @@ class HasslTransformer(Transformer):
|
|
|
663
503
|
workdays = params["workdays"] or ["mon", "tue", "wed", "thu", "fri"]
|
|
664
504
|
excludes = params["excludes"] or ["sat", "sun", "holiday"]
|
|
665
505
|
|
|
666
|
-
hs = nodes.HolidaySet(
|
|
667
|
-
|
|
668
|
-
country=country,
|
|
669
|
-
province=province,
|
|
670
|
-
add=add,
|
|
671
|
-
remove=remove,
|
|
672
|
-
workdays=workdays,
|
|
673
|
-
excludes=excludes,
|
|
674
|
-
)
|
|
506
|
+
hs = nodes.HolidaySet(id=str(ident) if ident is not None else "", country=country, province=province,
|
|
507
|
+
add=add, remove=remove, workdays=workdays, excludes=excludes)
|
|
675
508
|
self.stmts.append(hs)
|
|
676
509
|
return hs
|
|
677
510
|
|
|
678
|
-
# --- Holidays KV helpers (return ('key', value)) ---
|
|
679
|
-
|
|
680
|
-
# country="US"
|
|
681
511
|
def holi_country(self, s): return ("country", str(s))
|
|
682
|
-
# province="CA"
|
|
683
512
|
def holi_province(self, s): return ("province", str(s))
|
|
684
|
-
# workdays=[ mon, tue, ... ]
|
|
685
513
|
def holi_workdays(self, items): return ("workdays", items)
|
|
686
|
-
# excludes=[ sat, sun, holiday ]
|
|
687
514
|
def holi_excludes(self, items): return ("excludes", items)
|
|
688
|
-
# add=["YYYY-MM-DD", ...]
|
|
689
515
|
def holi_add(self, items): return ("add", items)
|
|
690
|
-
# remove=["YYYY-MM-DD", ...]
|
|
691
516
|
def holi_remove(self, items): return ("remove", items)
|
|
692
517
|
|
|
693
518
|
def daylist(self, *days): return [str(d) for d in days]
|
|
@@ -239,6 +239,33 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
239
239
|
|
|
240
240
|
# NEW: collect structured windows (serialize to plain dicts)
|
|
241
241
|
sched_windows: Dict[str, List[dict]] = {}
|
|
242
|
+
|
|
243
|
+
# --- helpers: normalization for day selector & holiday mode ---
|
|
244
|
+
def _norm_day_selector(ds: Optional[str]) -> str:
|
|
245
|
+
s = (ds or "").strip().lower()
|
|
246
|
+
if s in ("weekdays", "weekday", "wd", "mon-fri", "monfri"):
|
|
247
|
+
return "weekdays"
|
|
248
|
+
if s in ("weekends", "weekend", "we", "sat-sun", "satsun"):
|
|
249
|
+
return "weekends"
|
|
250
|
+
return "daily"
|
|
251
|
+
|
|
252
|
+
def _norm_holiday_mode(mode: Optional[str]) -> Optional[str]:
|
|
253
|
+
"""
|
|
254
|
+
Normalize holiday text to {'only','except',None}.
|
|
255
|
+
Accepts variants like:
|
|
256
|
+
'holiday', 'only holiday', 'holiday only' -> 'only'
|
|
257
|
+
'except holiday', 'exclude holiday', 'unless holiday', 'not holiday' -> 'except'
|
|
258
|
+
"""
|
|
259
|
+
if mode is None:
|
|
260
|
+
return None
|
|
261
|
+
m = str(mode).strip().lower().replace("_", " ").replace("-", " ")
|
|
262
|
+
# look for negation/exclusion first
|
|
263
|
+
if any(tok in m for tok in ("except", "exclude", "unless", "not")):
|
|
264
|
+
return "except"
|
|
265
|
+
if "holiday" in m or "only" in m:
|
|
266
|
+
return "only"
|
|
267
|
+
return None
|
|
268
|
+
|
|
242
269
|
for nm, wins in local_schedule_windows.items():
|
|
243
270
|
out: List[dict] = []
|
|
244
271
|
for w in wins:
|
|
@@ -249,14 +276,23 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
249
276
|
if getattr(w, "period", None):
|
|
250
277
|
p = w.period # PeriodSelector
|
|
251
278
|
period = {"kind": p.kind, "data": dict(p.data)}
|
|
279
|
+
|
|
280
|
+
# --- normalize selectors & holiday mode ---
|
|
281
|
+
day_sel = _norm_day_selector(getattr(w, "day_selector", None))
|
|
282
|
+
href = getattr(w, "holiday_ref", None)
|
|
283
|
+
hmode = _norm_holiday_mode(getattr(w, "holiday_mode", None))
|
|
284
|
+
# Heuristic default: if a weekdays/weekends selector references a holiday
|
|
285
|
+
# set and no mode provided, treat as "except" (workday semantics).
|
|
286
|
+
if href and hmode is None and day_sel in ("weekdays", "weekends"):
|
|
287
|
+
hmode = "except"
|
|
252
288
|
out.append({
|
|
253
289
|
"start": w.start,
|
|
254
290
|
"end": w.end,
|
|
255
|
-
"day_selector":
|
|
291
|
+
"day_selector": day_sel,
|
|
256
292
|
"period": period,
|
|
257
|
-
"holiday_ref":
|
|
258
|
-
"holiday_mode":
|
|
259
|
-
|
|
293
|
+
"holiday_ref": href,
|
|
294
|
+
"holiday_mode": hmode,
|
|
295
|
+
})
|
|
260
296
|
if out:
|
|
261
297
|
sched_windows[nm] = out
|
|
262
298
|
|
|
@@ -386,6 +422,16 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
386
422
|
# -------- NEW: validate schedule windows --------
|
|
387
423
|
allowed_days = {"weekdays", "weekends", "daily"}
|
|
388
424
|
for sched_name, wins in sched_windows.items():
|
|
425
|
+
# Normalize holiday modes defensively:
|
|
426
|
+
# If a window references a holiday set and also specifies a day bucket,
|
|
427
|
+
# it should be "except" (non-holiday behavior). We keep pure holiday-only
|
|
428
|
+
# windows (`day_selector == "daily"`) as "only".
|
|
429
|
+
for w in wins:
|
|
430
|
+
ds = (w.get("day_selector") or "daily").lower()
|
|
431
|
+
href = w.get("holiday_ref")
|
|
432
|
+
hmode = w.get("holiday_mode")
|
|
433
|
+
if href and ds in ("weekdays", "weekends") and (hmode is None or hmode == "only"):
|
|
434
|
+
w["holiday_mode"] = "except"
|
|
389
435
|
for w in wins:
|
|
390
436
|
ds = w.get("day_selector")
|
|
391
437
|
if ds not in allowed_days:
|
hassl-0.3.1/hassl/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|