hassl 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.1"
1
+ __version__ = "0.3.2"
hassl/codegen/package.py CHANGED
@@ -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")
hassl/parser/hassl.lark CHANGED
@@ -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
- | schedule_new_clause
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 (weekdays|weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
118
+ // [during <period>] on (weekdaysNo, |weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
118
119
  // on holidays <id> HH:MM-HH:MM ;
119
- schedule_new_clause: period? "on" day_selector time_range holiday_mod? ";"
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
- day_selector: "weekdays" | "weekends" | "daily"
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
 
hassl/parser/transform.py CHANGED
@@ -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
- # --- Program / Aliases / Syncs ---
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
- # alias: PRIVATE? "alias" CNAME "=" entity
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
- raise ValueError("package_decl: missing children")
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
- # Forms we might see:
139
- # (Token('.*'),) -> glob
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 # trust caller
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
- # --- Rules / if_clause ---
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
- start_idx = vals.index(name) + 1
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
- payload["for"] = vals[i + 1]; i += 2; continue
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
- # Lark may inline/flatten; take the first dict-ish child.
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
- return str(tok).lower()
365
-
366
- def schedule_to(self, _to_kw, ts):
367
- return {"to": ts}
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
- return children[0] if children else None
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
- Tolerant handler for:
401
- period? on (weekdays|weekends|daily) HH:MM-HH:MM [except holidays ID] ;
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
- prev_holidays = False # just saw the literal 'holidays'
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
- # time range
429
- if isinstance(p, tuple) and p and p[0] == "time":
430
- start, end = p[1], p[2]
431
- prev_holidays = False
432
- prev_except = False
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
- prev_except = True
452
- prev_holidays = False
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 = holiday_mode or ("except" if prev_except else "only")
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
- prev_except = True
473
- prev_holidays = False
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 = holiday_mode or ("except" if prev_except else "only")
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
- # anything else cancels the lookbehinds
491
- prev_holidays = False
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
- ident = str(a)
514
- elif isinstance(a, tuple) and a and a[0] == "time":
515
- start, end = a[1], a[2]
516
- elif isinstance(a, dict) and "start" in a and "end" in a:
517
- start, end = a["start"], a["end"]
518
- elif isinstance(a, str) and a not in ("on","holidays",";"):
519
- ident = a
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
- return "daily"
562
- tok = args[0]
563
- return str(tok)
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
- mode = "except"
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
- # Tokens
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
- continue
633
- if isinstance(ch, str): # just in case
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
- # defaults
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
- id=str(ident) if ident is not None else "",
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": w.day_selector,
291
+ "day_selector": day_sel,
256
292
  "period": period,
257
- "holiday_ref": w.holiday_ref,
258
- "holiday_mode": w.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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hassl
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: HASSL: Home Assistant Simple Scripting Language
5
5
  Home-page: https://github.com/adanowitz/hassl
6
6
  Author: adanowitz
@@ -1,23 +1,23 @@
1
- hassl/__init__.py,sha256=r4xAFihOf72W9TD-lpMi6ntWSTKTP2SlzKP1ytkjRbI,22
1
+ hassl/__init__.py,sha256=vNiWJ14r_cw5t_7UDqDQIVZvladKFGyHH2avsLpN7Vg,22
2
2
  hassl/cli.py,sha256=TSUvkoAg7ck1vlDUhC2sv_1NSetksdVGuOv-P9wrKxA,15762
3
3
  hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  hassl/ast/nodes.py,sha256=U_UTmWS87laFuFX39mmzkk43JnGx7NEyNbkSomfTzSM,2704
5
5
  hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
6
6
  hassl/codegen/generate.py,sha256=JmVBz8xhuPHosch0lhh8xU6tmdCsAl-qVWoY7hQxjow,206
7
7
  hassl/codegen/init.py,sha256=kMfi_Us_7c_T35OK_jo8eqb79lxNV-dToO9-iAb5fHI,55
8
- hassl/codegen/package.py,sha256=sGG40gonOoK3QJphO62Bju6WqFqfTT3XNx37QPHlb2w,42457
8
+ hassl/codegen/package.py,sha256=7c0hQaGYQ80gNynMghyETFwqYGR3Xp1_RlqWAs9_qw4,43124
9
9
  hassl/codegen/rules_min.py,sha256=LbLrpJ_2TV_FJlHB17TcuBaqfbLl97VUKzLck3s9KXo,29557
10
10
  hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
11
11
  hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- hassl/parser/hassl.lark,sha256=-At0mQQiUXxQU2aB1pxrk1WfQdQiUJeH7Ev8ada7kLg,5637
12
+ hassl/parser/hassl.lark,sha256=73c1wmHaTydRDIcVjIEsQLmfFcylIOfQyqkLvNhFryM,5729
13
13
  hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
14
- hassl/parser/transform.py,sha256=UXiUQBT3oMkFVJwFWA8X3L_Ds7PzZle7ZMBVVNSmp7Y,26332
14
+ hassl/parser/transform.py,sha256=Jg4y7bqrc14aA73SuvrfjyZlc6qs0LqqbNis5vzFqws,21721
15
15
  hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- hassl/semantics/analyzer.py,sha256=M8pQTFRYSpAyBo-ctIRuT1DBb_WZsGs7hjT4-pHPLjA,18066
16
+ hassl/semantics/analyzer.py,sha256=m6AvDVLJPzbRKMXJMcMbRjD1Ppl67NI7WEVN1DLudRo,20220
17
17
  hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
18
- hassl-0.3.1.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
19
- hassl-0.3.1.dist-info/METADATA,sha256=TYCCs2uU-7mRXyvq2_pkMLbVMQY_VTqW5Oi5i0msaJg,9006
20
- hassl-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
- hassl-0.3.1.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
22
- hassl-0.3.1.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
23
- hassl-0.3.1.dist-info/RECORD,,
18
+ hassl-0.3.2.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
19
+ hassl-0.3.2.dist-info/METADATA,sha256=K7Da3-A3a0Ih17mfj6wNLy2VYnmRvx21AH4bc9mzqK8,9006
20
+ hassl-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ hassl-0.3.2.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
22
+ hassl-0.3.2.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
23
+ hassl-0.3.2.dist-info/RECORD,,
File without changes