hassl 0.3.2__py3-none-any.whl → 0.4.0__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.2"
1
+ __version__ = "0.4.0"
hassl/ast/nodes.py CHANGED
@@ -58,6 +58,20 @@ class Schedule:
58
58
  windows: List[ScheduleWindow] = field(default_factory=list)
59
59
  private: bool = False
60
60
 
61
+ @dataclass
62
+ class TemplateDecl:
63
+ kind: str # "rule" | "sync" | "schedule"
64
+ name: str
65
+ params: list = field(default_factory=list) # [{"name": "light", "default": ...}, ...]
66
+ body: Any = None # Rule | Sync | Schedule body shape
67
+ private: bool = False
68
+
69
+ @dataclass
70
+ class UseTemplate:
71
+ name: str # template name being referenced
72
+ args: list = field(default_factory=list) # ["pos1", {"name":"kw","value":...}, ...]
73
+ as_name: Optional[str] = None
74
+
61
75
  @dataclass
62
76
  class Rule:
63
77
  name: str
@@ -75,7 +89,8 @@ class Program:
75
89
  def to_dict(self):
76
90
  def enc(x):
77
91
  if isinstance(x, (Alias, Sync, Rule, IfClause, Schedule,
78
- HolidaySet, ScheduleWindow, PeriodSelector)):
92
+ HolidaySet, ScheduleWindow, PeriodSelector,
93
+ TemplateDecl, UseTemplate)):
79
94
  d = asdict(x); d["type"] = x.__class__.__name__; return d
80
95
  return x
81
96
  return {
hassl/cli.py CHANGED
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
  from typing import Dict, Tuple, List
5
5
  from .parser.loader import load_grammar_text
6
6
  from .parser.transform import HasslTransformer
7
- from .ast.nodes import Program, Alias, Schedule
7
+ from .ast.nodes import Program, Alias, Schedule, TemplateDecl
8
8
  from lark import Lark
9
9
  from .semantics import analyzer as sem_analyzer
10
10
  from .semantics.analyzer import analyze
@@ -85,6 +85,12 @@ def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str],
85
85
  name = s.get("name")
86
86
  if isinstance(name, str) and name.strip():
87
87
  out[(pkg, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []) or [], private=False)
88
+
89
+ # Templates (public only)
90
+ for s in prog.statements:
91
+ if isinstance(s, TemplateDecl) and not getattr(s, "private", False):
92
+ out[(pkg, "template", s.name)] = s
93
+
88
94
  return out
89
95
 
90
96
  def _scan_hassl_files(path: Path) -> List[Path]:
@@ -97,40 +103,62 @@ def _module_to_path(module_root: Path, module: str) -> Path:
97
103
 
98
104
  def _ensure_imports_loaded(programs, module_root: Path):
99
105
  """If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
100
- # programs: List[tuple[Path, Program, str]]
106
+ # Track both package names (from parsed files) and module ids (from import statements)
101
107
  known_pkgs = {pkg for _, _, pkg in programs}
108
+ seen_modules: set[str] = set() # abs_mod we've already attempted/loaded
109
+ seen_paths: set[Path] = set(p for p, _, _ in programs)
110
+
102
111
  added = True
103
112
  while added:
104
113
  added = False
105
- for _, prog, importer_pkg in list(programs):
114
+ # iterate over a snapshot; we'll append to programs
115
+ for importer_path, prog, importer_pkg in list(programs):
106
116
  for imp in getattr(prog, "imports", []) or []:
107
117
  if not isinstance(imp, dict) or imp.get("type") != "import":
108
118
  continue
119
+
109
120
  raw_mod = imp.get("module", "")
110
- if not raw_mod:
121
+ if not raw_mod or not module_root:
111
122
  continue
112
- # resolve relative notation against the importing package
123
+
113
124
  abs_mod = _normalize_module(importer_pkg, raw_mod)
114
- if abs_mod in known_pkgs:
125
+
126
+ # guard: avoid re-trying the same module or self-import
127
+ if abs_mod in seen_modules or abs_mod == importer_pkg:
115
128
  continue
116
- if not module_root:
129
+
130
+ # If we already have a program whose package == abs_mod, skip
131
+ if abs_mod in known_pkgs:
132
+ seen_modules.add(abs_mod)
117
133
  continue
134
+
118
135
  candidate = _module_to_path(module_root, abs_mod)
119
- if candidate.exists():
120
- print(f"[hasslc] Autoload candidate FOUND for '{abs_mod}': {candidate}")
121
- with open(candidate, "r", encoding="utf-8") as f:
122
- text = f.read()
123
- p = parse_hassl(text)
124
- # force package to declared or derived (declared will win)
125
- # If the file declared a package, keep it. Otherwise, assign the resolved module id.
126
- pkg_name = p.package or abs_mod
127
- p.package = pkg_name
128
- programs.append((candidate, p, pkg_name))
129
- known_pkgs.add(pkg_name)
130
- added = True
131
- else:
136
+ if not candidate.exists():
132
137
  print(f"[hasslc] Autoload candidate MISS for '{abs_mod}': {candidate}")
138
+ seen_modules.add(abs_mod) # mark as tried to avoid spamming
139
+ continue
140
+
141
+ if candidate in seen_paths:
142
+ # We’ve already parsed this exact file; mark its module and move on
143
+ print(f"[hasslc] Autoload candidate SKIP (already loaded) for '{abs_mod}': {candidate}")
144
+ seen_modules.add(abs_mod)
145
+ continue
133
146
 
147
+ print(f"[hasslc] Autoload candidate FOUND for '{abs_mod}': {candidate}")
148
+ with open(candidate, "r", encoding="utf-8") as f:
149
+ text = f.read()
150
+
151
+ p = parse_hassl(text)
152
+ # prefer declared package; otherwise bind to module id
153
+ pkg_name = p.package or abs_mod
154
+ p.package = pkg_name
155
+
156
+ programs.append((candidate, p, pkg_name))
157
+ known_pkgs.add(pkg_name)
158
+ seen_modules.add(abs_mod)
159
+ seen_paths.add(candidate)
160
+ added = True
161
+
134
162
  def main():
135
163
  print("[hasslc] Using CLI file:", __file__)
136
164
  ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
@@ -197,174 +225,12 @@ def main():
197
225
 
198
226
  # Also drop a cross-project export table for debugging
199
227
  with open(out_root / "DEBUG_exports.json", "w", encoding="utf-8") as fp:
200
- printable = {f"{k[0]}::{k[1]}::{k[2]}": ("Alias" if isinstance(v, Alias) else "Schedule") for k, v in GLOBAL_EXPORTS.items()}
201
- json.dump(printable, fp, indent=2)
202
- print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
203
-
204
- if __name__ == "__main__":
205
- main()
206
- import argparse
207
- import os, json, glob
208
- from pathlib import Path
209
- from typing import Dict, Tuple, List
210
- from .parser.loader import load_grammar_text
211
- from .parser.transform import HasslTransformer
212
- from .ast.nodes import Program, Alias, Schedule
213
- from lark import Lark
214
- from .semantics import analyzer as sem_analyzer
215
- from .semantics.analyzer import analyze
216
- from .codegen.package import emit_package
217
- from .codegen import generate as codegen_generate
218
-
219
- def parse_hassl(text: str) -> Program:
220
- grammar = load_grammar_text()
221
- parser = Lark(grammar, start="start", parser="lalr", maybe_placeholders=False)
222
- tree = parser.parse(text)
223
- program = HasslTransformer().transform(tree)
224
- return program
225
-
226
- def _derive_package_name(prog: Program, src_path: Path, module_root: Path | None) -> str:
227
- """
228
- If the source did not declare `package`, derive one from the path:
229
- - If module_root is given and src_path is under it: use relative path (dots)
230
- - Else: use file stem
231
- """
232
- if getattr(prog, "package", None):
233
- return prog.package # declared
234
- if module_root:
235
- try:
236
- rel = src_path.resolve().relative_to(module_root.resolve())
237
- parts = list(rel.with_suffix("").parts)
238
- if parts:
239
- return ".".join(parts)
240
- except Exception:
241
- pass
242
- return src_path.stem
243
-
244
- def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str], object]:
245
- """
246
- Build (pkg, kind, name) -> node for public alias/schedule in a single Program.
247
- Accepts both Schedule nodes and transformer dicts {"type":"schedule_decl",...}.
248
- """
249
- out: Dict[Tuple[str,str,str], object] = {}
250
- # Aliases
251
- for s in prog.statements:
252
- if isinstance(s, Alias):
253
- if not getattr(s, "private", False):
254
- out[(pkg, "alias", s.name)] = s
255
- # Schedules (either dicts from transformer or Schedule nodes)
256
- for s in prog.statements:
257
- if isinstance(s, Schedule):
258
- if not getattr(s, "private", False):
259
- out[(pkg, "schedule", s.name)] = s
260
- elif isinstance(s, dict) and s.get("type") == "schedule_decl" and not s.get("private", False):
261
- name = s.get("name")
262
- if isinstance(name, str) and name.strip():
263
- out[(pkg, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []) or [], private=False)
264
- return out
265
-
266
- def _scan_hassl_files(path: Path) -> List[Path]:
267
- if path.is_file():
268
- return [path]
269
- return [Path(p) for p in glob.glob(str(path / "**" / "*.hassl"), recursive=True)]
270
-
271
- def _module_to_path(module_root: Path, module: str) -> Path:
272
- return (module_root / Path(module.replace(".", "/"))).with_suffix(".hassl")
273
-
274
- def _ensure_imports_loaded(programs, module_root: Path):
275
- """If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
276
- # programs: List[tuple[Path, Program, str]]
277
- known_pkgs = {pkg for _, _, pkg in programs}
278
- added = True
279
- while added:
280
- added = False
281
- for _, prog, _pkg in list(programs):
282
- for imp in getattr(prog, "imports", []) or []:
283
- if not isinstance(imp, dict) or imp.get("type") != "import":
284
- continue
285
- mod = imp.get("module", "")
286
- if not mod or mod in known_pkgs:
287
- continue
288
- if not module_root:
289
- continue
290
- candidate = _module_to_path(module_root, mod)
291
- if candidate.exists():
292
- print(f"[hasslc] Autoload candidate FOUND for '{mod}': {candidate}")
293
- with open(candidate, "r", encoding="utf-8") as f:
294
- text = f.read()
295
- p = parse_hassl(text)
296
- # force package to declared or derived (declared will win)
297
- pkg_name = p.package or mod
298
- p.package = pkg_name
299
- programs.append((candidate, p, pkg_name))
300
- known_pkgs.add(pkg_name)
301
- added = True
302
- else:
303
- print(f"[hasslc] Autoload candidate MISS for '{mod}': {candidate}")
304
-
305
- def main():
306
- ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
307
- ap.add_argument("input", help="Input .hassl file OR directory")
308
- ap.add_argument("-o", "--out", default="./packages/out", help="Output directory root for HA package(s)")
309
- ap.add_argument("--module-root", default=None, help="Optional root to derive package names from paths")
310
- args = ap.parse_args()
311
-
312
- in_path = Path(args.input)
313
- out_root = Path(args.out)
314
- module_root = Path(args.module_root).resolve() if args.module_root else None
315
-
316
- src_files = _scan_hassl_files(in_path)
317
- if not src_files:
318
- raise SystemExit(f"[hasslc] No .hassl files found in {in_path}")
319
-
320
- # Pass 0: parse all and assign/derive package names
321
- programs: List[tuple[Path, Program, str]] = []
322
- for p in src_files:
323
- with open(p, "r", encoding="utf-8") as f:
324
- text = f.read()
325
- prog = parse_hassl(text)
326
- pkg_name = _derive_package_name(prog, p, module_root)
327
- try:
328
- prog.package = pkg_name
329
- except Exception:
330
- pass
331
- programs.append((p, prog, pkg_name))
332
-
333
- # auto-load any missing imports from --module_root
334
- _ensure_imports_loaded(programs, module_root)
335
-
336
- # Pass 1: collect public exports across all programs
337
- GLOBAL_EXPORTS: Dict[Tuple[str,str,str], object] = {}
338
- for path, prog, pkg in programs:
339
- GLOBAL_EXPORTS.update(_collect_public_exports(prog, pkg))
340
-
341
- # publish global exports to analyzer
342
- sem_analyzer.GLOBAL_EXPORTS = GLOBAL_EXPORTS
343
-
344
- # Pass 2: analyze each program with global view
345
- os.makedirs(out_root, exist_ok=True)
346
- all_ir = []
347
- for path, prog, pkg in programs:
348
- print(f"[hasslc] Parsing {path} (package: {pkg})")
349
- print("[hasslc] AST:", json.dumps(prog.to_dict(), indent=2))
350
- ir = analyze(prog)
351
- print("[hasslc] IR:", json.dumps(ir.to_dict(), indent=2))
352
- all_ir.append((pkg, ir))
353
-
354
- # Emit: per package subdir
355
- for pkg, ir in all_ir:
356
- pkg_dir = out_root / pkg.replace(".", "_")
357
- os.makedirs(pkg_dir, exist_ok=True)
358
- ir_dict = ir.to_dict() if hasattr(ir, "to_dict") else ir
359
- codegen_generate(ir_dict, str(pkg_dir))
360
- emit_package(ir, str(pkg_dir))
361
- with open(pkg_dir / "DEBUG_ir.json", "w", encoding="utf-8") as dbg:
362
- dbg.write(json.dumps(ir.to_dict(), indent=2))
363
- print(f"[hasslc] Package written to {pkg_dir}")
364
-
365
- # Also drop a cross-project export table for debugging
366
- with open(out_root / "DEBUG_exports.json", "w", encoding="utf-8") as fp:
367
- printable = {f"{k[0]}::{k[1]}::{k[2]}": ("Alias" if isinstance(v, Alias) else "Schedule") for k, v in GLOBAL_EXPORTS.items()}
228
+ def _kind(v):
229
+ if isinstance(v, Alias): return "Alias"
230
+ if isinstance(v, Schedule): return "Schedule"
231
+ if isinstance(v, TemplateDecl): return "Template"
232
+ return type(v).__name__
233
+ printable = {f"{k[0]}::{k[1]}::{k[2]}": _kind(v) for k, v in GLOBAL_EXPORTS.items()}
368
234
  json.dump(printable, fp, indent=2)
369
235
  print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
370
236
 
hassl/codegen/package.py CHANGED
@@ -812,9 +812,13 @@ def emit_package(ir: IRProgram, outdir: str):
812
812
  if isinstance(ts, dict):
813
813
  return ts
814
814
  if isinstance(ts, str):
815
+ v = ts.strip()
815
816
  # accept "HH:MM" or "HH:MM:SS"
816
- v = ts if len(ts) in (5,8) else "00:00"
817
- return {"kind":"clock","value": v[:5] if len(v)==5 else v[:8]}
817
+ if len(v) == 5: # HH:MM
818
+ return {"kind":"clock","value": v}
819
+ if len(v) == 8: # HH:MM:SS
820
+ return {"kind":"clock","value": v}
821
+ return {"kind":"clock","value":"00:00"}
818
822
  return {"kind":"clock","value":"00:00"}
819
823
  start_ts = _coerce(raw_start)
820
824
  end_ts = _coerce(raw_end)
@@ -331,10 +331,15 @@ def _collect_schedules(ir: dict):
331
331
  inline_by_rule = {}
332
332
  use_by_rule = {}
333
333
 
334
- # top-level declared schedules
334
+ # top-level declared schedules (legacy clauses)
335
335
  schedules_obj = ir.get("schedules") or {}
336
336
  if isinstance(schedules_obj, dict):
337
337
  declared = {str(k): (v or []) for k, v in schedules_obj.items()}
338
+ # include window schedules as declared (no legacy clauses, but valid names)
339
+ windows_obj = ir.get("schedules_windows") or {}
340
+ if isinstance(windows_obj, dict):
341
+ for k in windows_obj.keys():
342
+ declared.setdefault(str(k), [])
338
343
 
339
344
  # per-rule data
340
345
  for rule in ir.get("rules", []):
@@ -359,7 +364,7 @@ def generate_rules(ir, outdir):
359
364
  # collect helper keys we must ensure exist
360
365
  ctx_inputs = {}
361
366
 
362
- # package slug for schedule sensor ids
367
+ # package slug for output file naming
363
368
  pkg = _pkg_slug(outdir)
364
369
 
365
370
  # --- schedules collection ---
@@ -385,16 +390,14 @@ def generate_rules(ir, outdir):
385
390
  for nm in (use_by_rule.get(rname, []) or []):
386
391
  base = str(nm).split(".")[-1]
387
392
  if base not in declared_base_names and (base not in exported_sched_pkgs):
388
- expected_sensor = _schedule_sensor(base, pkg)
389
393
  raise ValueError(
390
394
  "HASSL: schedule reference not found. "
391
395
  f"Rule '{rname}' uses schedule '{nm}', but no schedule named '{base}' "
392
- f"was declared in this package '{pkg}'.\n\n"
396
+ "was declared in this package.\n\n"
393
397
  "Declare it with:\n"
394
398
  f" schedule {base}:\n"
395
399
  " enable from <start> to <end>;\n\n"
396
400
  "Or ensure the schedule is declared in the same package.\n"
397
- f"(If you expected a sensor, codegen looks for: {expected_sensor})"
398
401
  )
399
402
 
400
403
  # NOTE: No helper creation here — package.py owns schedule sensors.
@@ -415,15 +418,6 @@ def generate_rules(ir, outdir):
415
418
  if rule_gates:
416
419
  for g in rule_gates:
417
420
  ents = [e for e in (g.get("entities") or []) if isinstance(e, str)]
418
- # Also include the legacy, current-outdir slug binary_sensor expected by older tests/code
419
- # Determine the base schedule name, then synthesize the local sensor id.
420
- resolved = str(g.get("resolved", "")) if isinstance(g.get("resolved", ""), str) else ""
421
- base = resolved.rsplit(".", 1)[-1] if resolved else None
422
- if base:
423
- legacy_local = _schedule_sensor(base, pkg) # e.g., binary_sensor.hassl_schedule_out_std_<base>_active
424
- if legacy_local not in ents:
425
- ents.append(legacy_local)
426
-
427
421
  if not ents:
428
422
  continue
429
423
  if len(ents) == 1:
@@ -593,11 +587,11 @@ def generate_rules(ir, outdir):
593
587
  "data": {"entity_id": full, "value": str(val)}
594
588
  })
595
589
  else:
596
- # Try light.turn_on data first; fallback to generic service data
597
- if eid.startswith("light."):
598
- act_list.append({"service": "light.turn_on", "target": {"entity_id": eid}, "data": {attr: val}})
599
- else:
600
- act_list.append({"service": "homeassistant.turn_on", "target": {"entity_id": eid}, "data": {attr: val}})
590
+ # Unknown action type: log and skip (keeps generator robust)
591
+ act_list.append({
592
+ "service": "logbook.log",
593
+ "data": {"name": "HASSL", "message": f"Unhandled action type: {act.get('type')}"}
594
+ })
601
595
 
602
596
  conds = [gate_cond] + sched_conds + [cond_ha]
603
597
  if qual_cond:
hassl/parser/hassl.lark CHANGED
@@ -2,6 +2,7 @@
2
2
  // + additive schedule support
3
3
  // + packages/imports + private visibility
4
4
 
5
+
5
6
  start: stmt*
6
7
 
7
8
  stmt: package_decl
@@ -11,6 +12,8 @@ stmt: package_decl
11
12
  | rule
12
13
  | schedule_decl
13
14
  | holidays_decl
15
+ | template_decl
16
+ | use_template_stmt
14
17
 
15
18
  // --- Aliases ---
16
19
  alias: PRIVATE? "alias" CNAME "=" entity //can mark private
@@ -18,11 +21,6 @@ alias: PRIVATE? "alias" CNAME "=" entity //can mark private
18
21
  // --- Sync ---
19
22
  sync: "sync" synctype "[" entity_list "]" "as" CNAME syncopts?
20
23
 
21
- ONOFF: "onoff"
22
- DIMMER: "dimmer"
23
- ATTRIBUTE: "attribute"
24
- SHARED: "shared"
25
- ALL: "all"
26
24
  synctype: ONOFF | DIMMER | ATTRIBUTE | SHARED | ALL
27
25
 
28
26
  syncopts: "{" ("invert" ":" entity_list) "}"
@@ -74,7 +72,7 @@ assign: CNAME "=" STATE ("for" dur)?
74
72
  attr_assign: CNAME "." CNAME ("." CNAME)* "." CNAME "=" NUMBER
75
73
  | CNAME "." CNAME "=" NUMBER
76
74
 
77
- waitact: "wait" "(" condition "for" dur ")" action
75
+ waitact: "wait" "(" condition "for" (dur | CNAME | STRING) ")" action
78
76
  // condition used by wait(): allow expr + optional qualifier inside the parens
79
77
  condition: expr qualifier?
80
78
 
@@ -88,20 +86,6 @@ UNIT: "ms" | "s" | "m" | "h" | "d"
88
86
  // Timepoint placeholder (kept simple)
89
87
  timepoint: CNAME
90
88
 
91
- SCHEDULE: "schedule"
92
- USE: "use"
93
- FROM: "from"
94
- TO: "to"
95
- UNTIL: "until"
96
- ENABLE: "enable"
97
- DISABLE: "disable"
98
- SUNRISE: "sunrise"
99
- SUNSET: "sunset"
100
- PRIVATE: "private"
101
-
102
- TIME_HHMM: /[0-2]?\d:[0-5]\d/
103
- OFFSET: /[+-]\d+(ms|s|m|h|d)/
104
-
105
89
  // Reusable named schedule declarations
106
90
  schedule_decl: PRIVATE? SCHEDULE CNAME ":" schedule_clause+
107
91
 
@@ -132,9 +116,6 @@ MMDD: /\d{2}-\d{2}/
132
116
  ymd_range: YMD ".." YMD
133
117
  YMD: /\d{4}-\d{2}-\d{2}/
134
118
 
135
- WEEKDAYS: "weekdays"
136
- WEEKENDS: "weekends"
137
- DAILY: "daily"
138
119
  day_selector: WEEKDAYS | WEEKENDS | DAILY
139
120
  time_range: TIME_HHMM "-" TIME_HHMM
140
121
  holiday_mod: "except" "holidays" CNAME
@@ -144,7 +125,7 @@ schedule_end: TO time_spec -> schedule_to
144
125
  | UNTIL time_spec -> schedule_until
145
126
 
146
127
  // Rule-level usage
147
- rule_schedule_use: SCHEDULE USE name_list ";"
128
+ rule_schedule_use: SCHEDULE USE name_list ";"?
148
129
  // allow qualified (pkg.symbol) via existing 'entity' rule
149
130
  name_list: name ("," name)*
150
131
  name: CNAME | entity
@@ -166,12 +147,41 @@ member: entity | CNAME
166
147
 
167
148
  entity: CNAME ("." CNAME)+
168
149
 
150
+
169
151
  // ---- Lexer ----
170
- %import common.CNAME
171
- %import common.WS
152
+ // Order matters: keyword terminals first so they take precedence over CNAME
153
+ SCHEDULE: "schedule"
154
+ USE: "use"
155
+ FROM: "from"
156
+ TO: "to"
157
+ UNTIL: "until"
158
+ ENABLE: "enable"
159
+ DISABLE: "disable"
160
+ SUNRISE: "sunrise"
161
+ SUNSET: "sunset"
162
+ PRIVATE: "private"
163
+ TEMPLATE: "template"
164
+
165
+ ONOFF: "onoff"
166
+ DIMMER: "dimmer"
167
+ ATTRIBUTE: "attribute"
168
+ SHARED: "shared"
169
+ ALL: "all"
170
+ WEEKDAYS: "weekdays"
171
+ WEEKENDS: "weekends"
172
+ DAILY: "daily"
173
+
172
174
  %import common.ESCAPED_STRING -> STRING
173
175
  %import common.INT
174
176
  %import common.SIGNED_NUMBER
177
+ %import common.WS
178
+
179
+ // Override CNAME after keywords, so keywords win
180
+ CNAME: /[a-zA-Z_][a-zA-Z0-9_]*/
181
+
182
+ TIME_HHMM: /[0-2]?\d:[0-5]\d/
183
+ OFFSET: /[+-]\d+(ms|s|m|h|d)/
184
+
175
185
  LINE_COMMENT: /\/\/[^\n]*/
176
186
  %ignore LINE_COMMENT
177
187
  %ignore WS
@@ -192,7 +202,7 @@ import_tail: ".*"
192
202
  import_list: import_item ("," import_item)*
193
203
  import_item: CNAME ("as" CNAME)?
194
204
 
195
- // ---- Holidays declaration (NEW) ----
205
+ // ---- Holidays declaration ----
196
206
  // Example:
197
207
  // holidays us_ca:
198
208
  // country="US", province="CA", add=["2025-11-28"], remove=["2025-12-26"]
@@ -207,4 +217,28 @@ daylist: DAY ("," DAY)*
207
217
  excludelist: ("sat"|"sun"|"holiday") ("," ("sat"|"sun"|"holiday"))*
208
218
  DAY.2: "mon"|"tue"|"wed"|"thu"|"fri"|"sat"|"sun"
209
219
  DATESTR: /"\d{4}-\d{2}-\d{2}"/
210
- datestr_list: DATESTR ("," DATESTR)*
220
+ datestr_list: DATESTR ("," DATESTR)*
221
+
222
+ // === Template
223
+ template_decl: PRIVATE? TEMPLATE template_kind CNAME "(" template_params? ")" ":" template_body
224
+ template_kind: "rule" | "sync" | "schedule"
225
+ template_params: template_param ("," template_param)*
226
+ template_param: CNAME ("=" template_default)?
227
+ template_default: NUMBER | STRING | CNAME
228
+
229
+ template_body: rule_body -> template_rule_body
230
+ | sync_body -> template_sync_body
231
+ | schedule_body -> template_schedule_body
232
+
233
+ // --- Bodies used by template_body (define these ONCE) ---
234
+ rule_body: rule_clause+ // clauses without 'rule NAME:'
235
+ sync_body: synctype "[" entity_list "]" syncopts?
236
+ schedule_body: schedule_clause+ // clauses without 'schedule NAME:'
237
+
238
+ // Reuse existing pieces parsed elsewhere (no duplicates below this line)
239
+
240
+ // --- Template invocation ---
241
+ use_template_stmt: "use" "template" CNAME "(" call_args? ")" ("as" CNAME)?
242
+ call_args: call_arg ("," call_arg)*
243
+ call_arg: (CNAME "=" value) | value
244
+ value: NUMBER | STRING | entity | CNAME
hassl/parser/transform.py CHANGED
@@ -92,6 +92,11 @@ class HasslTransformer(Transformer):
92
92
  def import_tail(self, *args):
93
93
  if len(args) == 1 and isinstance(args[0], Token) and str(args[0]) == ".*":
94
94
  return ("glob", [], None)
95
+ # Some parses drop the literal "as" token; treat a single CNAME as alias target.
96
+ if len(args) == 1 and isinstance(args[0], (Token, str)):
97
+ val = str(args[0])
98
+ if val and val != ".*":
99
+ return ("alias", [], val)
95
100
  if len(args) == 2:
96
101
  a0, a1 = args
97
102
  if isinstance(a0, Token) and str(a0) == ":":
@@ -109,6 +114,107 @@ class HasslTransformer(Transformer):
109
114
  return {"name": str(parts[0]), "as": None}
110
115
  return {"name": str(parts[0]), "as": str(parts[-1])}
111
116
 
117
+ # ============ Templates (NEW) ============
118
+ # These are no-ops unless your grammar includes template_* rules.
119
+ # They simply build nodes and push them into self.stmts so the analyzer can expand them.
120
+
121
+ # template_decl: PRIVATE? TEMPLATE template_kind CNAME "(" template_params? ")" ":" template_body
122
+ def template_decl(self, *parts):
123
+ private = any(isinstance(p, Token) and p.type == "PRIVATE" for p in parts)
124
+ kind = next((str(p) for p in parts if isinstance(p, str) and p in ("rule","sync","schedule")), "rule")
125
+ name = None
126
+ params = []
127
+ body = None
128
+ seen_paren = False
129
+ for p in parts:
130
+ if isinstance(p, Token) and p.type == "CNAME" and name is None:
131
+ name = str(p); continue
132
+ # parameter list arrives as a python list (via template_params)
133
+ if isinstance(p, list) and p and isinstance(p[0], dict) and "name" in p[0]:
134
+ params = p; continue
135
+ # the first non-token/non-list after ':' should be the body node (Rule/Sync/Schedule body)
136
+ if not isinstance(p, (list, Token, str)) and p is not None:
137
+ body = p
138
+ t = nodes.TemplateDecl(kind=kind, name=str(name) if name else "", params=params, body=body, private=private)
139
+ self.stmts.append(t)
140
+ return t
141
+
142
+ def template_kind(self, *toks):
143
+ if not toks:
144
+ return "rule"
145
+ tok = toks[0]
146
+ return str(tok)
147
+
148
+ def template_params(self, *items):
149
+ return list(items)
150
+
151
+ # template_param: CNAME ("=" template_default)?
152
+ def template_param(self, name, default=None):
153
+ return {"name": str(name), "default": default}
154
+
155
+ # template_default: NUMBER | STRING | CNAME
156
+ def template_default(self, val):
157
+ return _atom(val)
158
+
159
+ # template_body variants – just forward the inner node as-is
160
+ def template_rule_body(self, body): return body
161
+ def template_sync_body(self, body): return body
162
+ def template_schedule_body(self, body): return body
163
+
164
+ # New: rule_body / schedule_body nodes for templates
165
+ def rule_body(self, *clauses):
166
+ # Produce a Rule node with empty name; analyzer will rename on instantiation
167
+ return nodes.Rule(name="", clauses=list(clauses))
168
+
169
+ def schedule_body(self, *clauses):
170
+ # Produce a Schedule node with empty name; analyzer will rename on instantiation
171
+ return nodes.Schedule(name="", clauses=list(clauses), windows=[])
172
+
173
+ def sync_body(self, synctype, members, syncopts=None):
174
+ invert = syncopts if isinstance(syncopts, list) else []
175
+ # name is filled later if you 'use template ... as <name>'
176
+ return nodes.Sync(kind=str(synctype), members=members, name="", invert=invert)
177
+
178
+ # use_template_stmt: "use" "template" CNAME "(" call_args? ")" ("as" CNAME)?
179
+ def use_template_stmt(self, *parts):
180
+ name = None
181
+ as_name = None
182
+ args = []
183
+ seen_as = False
184
+ for p in parts:
185
+ if isinstance(p, Token) and p.type == "CNAME":
186
+ if name is None:
187
+ name = str(p)
188
+ elif seen_as and as_name is None:
189
+ as_name = str(p)
190
+ # else: ignore stray CNAMEs (shouldn't happen if grammar is strict)
191
+ continue
192
+ if isinstance(p, list):
193
+ # call_args comes in as a list
194
+ args = p
195
+ continue
196
+ if isinstance(p, str) and p == "as":
197
+ seen_as = True
198
+ continue
199
+ u = nodes.UseTemplate(name=str(name) if name else "", args=args, as_name=as_name)
200
+ self.stmts.append(u)
201
+ return u
202
+
203
+ # call_args: call_arg ("," call_arg)*
204
+ def call_args(self, *items):
205
+ return list(items)
206
+
207
+ # call_arg: (CNAME "=" value) | value
208
+ def call_arg(self, *parts):
209
+ if len(parts) == 1:
210
+ return parts[0]
211
+ # name "=" value
212
+ return {"name": str(parts[0]), "value": parts[-1]}
213
+
214
+ # value: NUMBER | STRING | entity | CNAME
215
+ def value(self, v):
216
+ # Normalize to primitives/strings like other atoms; entities are already normalized by entity()
217
+ return _atom(v)
112
218
  # ============ Aliases / Sync ============
113
219
  def alias(self, *args):
114
220
  private = False
@@ -193,8 +299,11 @@ class HasslTransformer(Transformer):
193
299
  return {"type": "attr_assign", "entity": entity, "attr": attr, "value": value}
194
300
 
195
301
  def waitact(self, cond, dur, action):
196
- return {"type": "wait", "condition": cond, "for": dur, "then": action}
302
+ return {"type": "wait", "condition": cond, "for": _atom(dur), "then": action}
197
303
 
304
+ def dur_arg(self, val):
305
+ return _atom(val)
306
+
198
307
  def rulectrl(self, *parts):
199
308
  def s(x): return str(x) if isinstance(x, Token) else x
200
309
  vals = [s(p) for p in parts]
@@ -296,33 +405,6 @@ class HasslTransformer(Transformer):
296
405
  def time_spec(self, *children): return children[0] if children else None
297
406
  def rule_clause(self, item): return item
298
407
 
299
- def sched_holiday_only(self, *args):
300
- """
301
- Handles: on holidays <CNAME> HH:MM-HH:MM ;
302
- Args arrive as ( 'on', 'holidays', <CNAME token>, time_range_tuple, ';' )
303
- """
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
408
  # -------- New windows & periods --------
327
409
  def schedule_window_clause(self, *parts):
328
410
  # Reset sticky day for each clause
@@ -1,8 +1,10 @@
1
- from dataclasses import dataclass, field
1
+ from dataclasses import dataclass, field, is_dataclass, asdict
2
2
  from typing import Dict, List, Any, Optional, Tuple
3
+ import copy
3
4
  from ..ast.nodes import (
4
5
  Program, Alias, Sync, Rule, Schedule,
5
6
  HolidaySet, ScheduleWindow, PeriodSelector,
7
+ TemplateDecl, UseTemplate,
6
8
  )
7
9
  from .domains import DOMAIN_PROPS, domain_of
8
10
 
@@ -60,6 +62,20 @@ class IRProgram:
60
62
  "schedules_windows": self.schedules_windows or {}, # NEW
61
63
  }
62
64
 
65
+ def _resolve_module_id(raw_mod: str) -> str:
66
+ """
67
+ Map a raw import like 'hall.aliases' to the actual package id present in GLOBAL_EXPORTS,
68
+ e.g. 'home.hall.aliases'. If multiple candidates match, keep raw (fail gently).
69
+ """
70
+ if 'GLOBAL_EXPORTS' not in globals() or not raw_mod:
71
+ return raw_mod
72
+ # exact hit?
73
+ if any(pkg == raw_mod for (pkg, _k, _n) in globals()['GLOBAL_EXPORTS']):
74
+ return raw_mod
75
+ # suffix match (common when files declare 'home.hall.aliases' but source wrote 'hall.aliases')
76
+ candidates = {pkg for (pkg, _k, _n) in globals()['GLOBAL_EXPORTS'] if pkg.endswith("." + raw_mod)}
77
+ return next(iter(candidates)) if len(candidates) == 1 else raw_mod
78
+
63
79
  def _resolve_alias(e: str, amap: Dict[str,str]) -> str:
64
80
  if "." not in e and e in amap: return amap[e]
65
81
  return e
@@ -114,8 +130,148 @@ def analyze(prog: Program) -> IRProgram:
114
130
  local_schedule_windows: Dict[str, List[ScheduleWindow]] = {}
115
131
  local_holidays: Dict[str, HolidaySet] = {}
116
132
 
117
- # 1) Collect local declarations (aliases & schedules)
118
- for s in prog.statements:
133
+ # 0) Preprocess: collect templates and expand 'use template' into concrete nodes
134
+ templates_by_kind: Dict[str, Dict[str, TemplateDecl]] = {"rule": {}, "sync": {}, "schedule": {}}
135
+
136
+ # Collect modules this program imports (treat bare "import X" as glob, same as you already do)
137
+ imported_modules = set()
138
+ for imp in getattr(prog, "imports", []) or []:
139
+ if not isinstance(imp, dict) or imp.get("type") != "import":
140
+ continue
141
+ raw_mod = imp.get("module", "")
142
+ mod = _resolve_module_id(raw_mod)
143
+
144
+ if not mod:
145
+ continue
146
+ # normalize kind the same way you do later
147
+ kind = imp.get("kind")
148
+ if kind not in ("glob", "list", "alias"):
149
+ if imp.get("items"): kind = "list"
150
+ elif imp.get("as"): kind = "alias"
151
+ else: kind = "glob"
152
+
153
+ if kind in ("glob", "list", "alias"):
154
+ imported_modules.add(mod)
155
+
156
+ # Helper: build arg map (params -> values) using defaults
157
+ def _bind_args(t: TemplateDecl, call_args: List[Any]) -> Dict[str, Any]:
158
+ params = list(t.params or [])
159
+ # normalize params: [{"name":..., "default":...}, ...]
160
+ pnames = [p.get("name") for p in params]
161
+ defaults = {p.get("name"): p.get("default") for p in params}
162
+ bound: Dict[str, Any] = dict(defaults)
163
+ # split named vs positional args from transformer
164
+ pos: List[Any] = []
165
+ named: Dict[str, Any] = {}
166
+ for a in call_args or []:
167
+ if isinstance(a, dict) and "name" in a:
168
+ named[str(a["name"])] = a.get("value")
169
+ else:
170
+ pos.append(a)
171
+ # apply positional
172
+ for i, v in enumerate(pos):
173
+ if i < len(pnames):
174
+ bound[pnames[i]] = v
175
+ # apply named (wins over positional/default)
176
+ for k, v in named.items():
177
+ if k in pnames:
178
+ bound[k] = v
179
+ return bound
180
+
181
+ # Deep substitute param identifiers appearing as bare strings in nested dict/list trees
182
+ def _deep_subst(obj: Any, subst: Dict[str, Any]) -> Any:
183
+ # strings: replace only if exactly matches a parameter name
184
+ if isinstance(obj, str):
185
+ return str(subst.get(obj, obj))
186
+ # dicts/lists: walk recursively
187
+ if isinstance(obj, dict):
188
+ return {k: _deep_subst(v, subst) for k, v in obj.items()}
189
+ if isinstance(obj, list):
190
+ return [_deep_subst(x, subst) for x in obj]
191
+ # leave everything else as-is (numbers, bools, None)
192
+ return obj
193
+
194
+ # Expand a single UseTemplate into a concrete node (Rule/Sync/Schedule)
195
+ def _instantiate(use: UseTemplate) -> Optional[Any]:
196
+ # Find matching template by name across kinds (prefer rule->sync->schedule)
197
+ t = None
198
+ for kind in ("rule", "sync", "schedule"):
199
+ t = templates_by_kind.get(kind, {}).get(use.name)
200
+ if t:
201
+ break
202
+ if not t:
203
+ return None
204
+ argmap = _bind_args(t, list(getattr(use, "args", []) or []))
205
+
206
+ if t.body is None:
207
+ # Provide minimal empty bodies so deep_subst & constructors don’t break
208
+ if t.kind == "rule":
209
+ t.body = Rule(name="", clauses=[])
210
+ elif t.kind == "sync":
211
+ t.body = Sync(kind="onoff", members=[], name="", invert=[])
212
+ elif t.kind == "schedule":
213
+ t.body = Schedule(name="", clauses=[], windows=[], private=False)
214
+ original = copy.deepcopy(t.body)
215
+ # Plainify dataclasses/objects so deep_subst can walk them
216
+ if is_dataclass(original):
217
+ plain = asdict(original)
218
+ elif hasattr(original, "__dict__"):
219
+ # shallow mapping of fields; they will typically be lists/dicts we can walk
220
+ plain = copy.deepcopy(vars(original))
221
+ else:
222
+ plain = original
223
+ subbed = _deep_subst(plain, argmap)
224
+
225
+ # Rename resulting node if caller provided "as <name>"
226
+ # Rename resulting node if caller provided "as <name>" or passed name= param
227
+ new_name = getattr(use, "as_name", None) or str(argmap.get("name") or t.name)
228
+
229
+ # Construct concrete AST node of same kind
230
+ if isinstance(t.body, Rule) or (getattr(t.body, "__class__", None).__name__ == "Rule"):
231
+ # subbed is a dict after plainify/subst
232
+ return Rule(name=new_name, clauses=subbed.get("clauses", getattr(original, "clauses", [])))
233
+ if isinstance(t.body, Sync) or (getattr(t.body, "__class__", None).__name__ == "Sync"):
234
+ return Sync(kind=subbed.get("kind", getattr(original, "kind", "onoff")),
235
+ members=subbed.get("members", getattr(original, "members", [])),
236
+ name=new_name,
237
+ invert=subbed.get("invert", getattr(original, "invert", [])))
238
+ if isinstance(t.body, Schedule) or (getattr(t.body, "__class__", None).__name__ == "Schedule"):
239
+ return Schedule(name=new_name,
240
+ clauses=subbed.get("clauses", getattr(original, "clauses", [])),
241
+ windows=subbed.get("windows", getattr(original, "windows", [])),
242
+ private=subbed.get("private", getattr(original, "private", False)))
243
+ return None
244
+
245
+ # Scan once to collect templates
246
+ for s in getattr(prog, "statements", []) or []:
247
+ if isinstance(s, TemplateDecl):
248
+ kind = (s.kind or "rule").lower()
249
+ if kind in templates_by_kind:
250
+ templates_by_kind[kind][s.name] = s
251
+
252
+ if 'GLOBAL_EXPORTS' in globals():
253
+ for (pkg, kind, name), node in globals()['GLOBAL_EXPORTS'].items():
254
+ if kind != "template":
255
+ continue
256
+ # bring templates from any imported module
257
+ if pkg in imported_modules and isinstance(node, TemplateDecl):
258
+ tkind = (node.kind or "rule").lower()
259
+ if tkind in templates_by_kind and name not in templates_by_kind[tkind]:
260
+ templates_by_kind[tkind][name] = node
261
+
262
+ # Build a new statement list with uses expanded
263
+ expanded_statements: List[Any] = []
264
+ for s in getattr(prog, "statements", []) or []:
265
+ if isinstance(s, UseTemplate):
266
+ inst = _instantiate(s)
267
+ if inst is not None:
268
+ expanded_statements.append(inst)
269
+ # do not append the UseTemplate node itself
270
+ else:
271
+ expanded_statements.append(s)
272
+
273
+ # 1) Collect local declarations (aliases & schedules) from expanded statements
274
+ for s in expanded_statements:
119
275
  if isinstance(s, Alias):
120
276
  local_aliases[s.name] = s.entity
121
277
  is_private = getattr(s, "private", False)
@@ -173,7 +329,9 @@ def analyze(prog: Program) -> IRProgram:
173
329
  if not isinstance(imp, dict) or imp.get("type") != "import":
174
330
  # transformer may also append sentinels to statements; ignore here
175
331
  continue
176
- mod = imp.get("module", "")
332
+ raw_mod = imp.get("module", "")
333
+ mod = _resolve_module_id(raw_mod)
334
+
177
335
  kind = imp.get("kind")
178
336
  # Be generous: if transformer emitted "none" or omitted kind, infer it.
179
337
  if kind not in ("glob", "list", "alias"):
@@ -224,7 +382,7 @@ def analyze(prog: Program) -> IRProgram:
224
382
 
225
383
  # --- Syncs ---
226
384
  syncs: List[IRSync] = []
227
- for s in prog.statements:
385
+ for s in expanded_statements:
228
386
  if isinstance(s, Sync):
229
387
  mem = [_resolve_alias(m,amap) for m in s.members]
230
388
  inv = [_resolve_alias(m,amap) for m in s.invert]
@@ -374,8 +532,22 @@ def analyze(prog: Program) -> IRProgram:
374
532
  if ent:
375
533
  return ent
376
534
  return obj
535
+
536
+ # Resolve only qualified aliases (ns.alias) and leave unqualified aliases intact.
537
+ # This keeps existing IR expectations while making qualified references usable.
538
+ def _walk_qualified_only(obj: Any) -> Any:
539
+ if isinstance(obj, dict):
540
+ return {k: _walk_qualified_only(v) for k, v in obj.items()}
541
+ if isinstance(obj, list):
542
+ return [_walk_qualified_only(x) for x in obj]
543
+ if isinstance(obj, str):
544
+ if "." in obj:
545
+ ent = _resolve_qualified_alias(obj)
546
+ if ent:
547
+ return ent
548
+ return obj
377
549
 
378
- for s in prog.statements:
550
+ for s in expanded_statements:
379
551
  if isinstance(s, Rule):
380
552
  clauses: List[dict] = []
381
553
  schedule_uses: List[str] = []
@@ -387,8 +559,9 @@ def analyze(prog: Program) -> IRProgram:
387
559
  # IfClause-like items have .condition/.actions
388
560
  if hasattr(c, "condition") and hasattr(c, "actions"):
389
561
  # Keep alias identifiers intact for tests & codegen (resolve later)
390
- cond = c.condition
391
- acts = c.actions
562
+ # But resolve qualified aliases (ns.alias) so codegen gets real entities
563
+ cond = _walk_qualified_only(c.condition)
564
+ acts = _walk_qualified_only(c.actions)
392
565
  clauses.append({"condition": cond, "actions": acts})
393
566
  elif isinstance(c, dict) and c.get("type") == "schedule_use":
394
567
  # {"type":"schedule_use","names":[...]}
@@ -407,6 +580,10 @@ def analyze(prog: Program) -> IRProgram:
407
580
  for sc in c.get("clauses") or []:
408
581
  if isinstance(sc, dict):
409
582
  schedules_inline.append(sc)
583
+ elif isinstance(c, dict) and "condition" in c and "actions" in c:
584
+ cond = _walk_alias_with_qualified(c["condition"])
585
+ acts = _walk_alias_with_qualified(c["actions"])
586
+ clauses.append({"condition": cond, "actions": acts})
410
587
  else:
411
588
  # ignore unknown fragments
412
589
  pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hassl
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: HASSL: Home Assistant Simple Scripting Language
5
5
  Home-page: https://github.com/adanowitz/hassl
6
6
  Author: adanowitz
@@ -17,7 +17,7 @@ Dynamic: license-file
17
17
 
18
18
  > **Home Assistant Simple Scripting Language**
19
19
 
20
- ![Version](https://img.shields.io/badge/version-v0.3.1-blue)
20
+ ![Version](https://img.shields.io/badge/version-v0.4.0-blue)
21
21
 
22
22
  HASSL is a human-friendly domain-specific language (DSL) for building **loop-safe**, **deterministic**, and **composable** automations for [Home Assistant](https://www.home-assistant.io/).
23
23
 
@@ -30,7 +30,7 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
30
30
  - **Readable DSL** → write logic like natural language (`if motion && lux < 50 then light = on`)
31
31
  - **Sync devices** → keep switches, dimmers, and fans perfectly in sync
32
32
  - **Schedules** → declare time-based gates (`enable from 08:00 until 19:00`)
33
- - **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.3.1)
33
+ - **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.4.0)
34
34
  - **Loop-safe** → context ID tracking prevents feedback loops
35
35
  - **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
36
36
  - **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
@@ -148,7 +148,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
148
148
  | `scripts_<pkg>.yaml` | Writer scripts with context stamping |
149
149
  | `sync_<pkg>_*.yaml` | Sync automations for each property |
150
150
  | `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
151
- | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.3.1) |
151
+ | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.4.0) |
152
152
 
153
153
  ---
154
154
 
@@ -181,7 +181,7 @@ All schedules are restart-safe:
181
181
 
182
182
  ---
183
183
 
184
- ## 🗓️ Holiday & Workday Integration (v0.3.1)
184
+ ## 🗓️ Holiday & Workday Integration (v0.4.0)
185
185
 
186
186
  HASSL now supports `holidays <id>:` schedules tied to Home Assistant’s **Workday** integration.
187
187
 
@@ -238,7 +238,7 @@ Once created, HASSL automatically references them in generated automations.
238
238
 
239
239
  ## ⚗️ Experimental: Date & Month Range Schedules
240
240
 
241
- HASSL v0.3.1 includes early support for:
241
+ HASSL v0.4.0 includes early support for:
242
242
 
243
243
  ```hassl
244
244
  on months Jun–Aug 07:00–22:00;
@@ -0,0 +1,23 @@
1
+ hassl/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
2
+ hassl/cli.py,sha256=8y15glAYjHsJChaHefwqvoC5sz-l-pt9E9mWtzrt-Ik,9890
3
+ hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ hassl/ast/nodes.py,sha256=Z6Ac5RrDEv6KNAAxdKQ3wrnnoHqbgP6-SDvda9Z8CNk,3254
5
+ hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
6
+ hassl/codegen/generate.py,sha256=JmVBz8xhuPHosch0lhh8xU6tmdCsAl-qVWoY7hQxjow,206
7
+ hassl/codegen/init.py,sha256=kMfi_Us_7c_T35OK_jo8eqb79lxNV-dToO9-iAb5fHI,55
8
+ hassl/codegen/package.py,sha256=wruN26-wo64RBb94SxyEhG_4NIc6zBFcvOhGpuO_CIA,43289
9
+ hassl/codegen/rules_min.py,sha256=3hiL0dUvlU9uIf1kc_BYcyTsaWGUBS787WkaIh1vZwk,28909
10
+ hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
11
+ hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ hassl/parser/hassl.lark,sha256=aBlZ609bZ-Wnrs5EsFukDqAIo5xLNQ6p2ruS5qmxtI8,6978
13
+ hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
14
+ hassl/parser/transform.py,sha256=xVz1fKjfxHbiTeUFuZJbVwctGWwd4cdvAPhfKgslwbs,25328
15
+ hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ hassl/semantics/analyzer.py,sha256=layYd1x_UajNPZN9Up_fp2dfApnBzCyeoG351nutpjU,28641
17
+ hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
18
+ hassl-0.4.0.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
19
+ hassl-0.4.0.dist-info/METADATA,sha256=Noo52fAx3gT0U5s0hrNMeia8BLnLgXnXsuSkhKpyZZE,9006
20
+ hassl-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
+ hassl-0.4.0.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
22
+ hassl-0.4.0.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
23
+ hassl-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,23 +0,0 @@
1
- hassl/__init__.py,sha256=vNiWJ14r_cw5t_7UDqDQIVZvladKFGyHH2avsLpN7Vg,22
2
- hassl/cli.py,sha256=TSUvkoAg7ck1vlDUhC2sv_1NSetksdVGuOv-P9wrKxA,15762
3
- hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- hassl/ast/nodes.py,sha256=U_UTmWS87laFuFX39mmzkk43JnGx7NEyNbkSomfTzSM,2704
5
- hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
6
- hassl/codegen/generate.py,sha256=JmVBz8xhuPHosch0lhh8xU6tmdCsAl-qVWoY7hQxjow,206
7
- hassl/codegen/init.py,sha256=kMfi_Us_7c_T35OK_jo8eqb79lxNV-dToO9-iAb5fHI,55
8
- hassl/codegen/package.py,sha256=7c0hQaGYQ80gNynMghyETFwqYGR3Xp1_RlqWAs9_qw4,43124
9
- hassl/codegen/rules_min.py,sha256=LbLrpJ_2TV_FJlHB17TcuBaqfbLl97VUKzLck3s9KXo,29557
10
- hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
11
- hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- hassl/parser/hassl.lark,sha256=73c1wmHaTydRDIcVjIEsQLmfFcylIOfQyqkLvNhFryM,5729
13
- hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
14
- hassl/parser/transform.py,sha256=Jg4y7bqrc14aA73SuvrfjyZlc6qs0LqqbNis5vzFqws,21721
15
- hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- hassl/semantics/analyzer.py,sha256=m6AvDVLJPzbRKMXJMcMbRjD1Ppl67NI7WEVN1DLudRo,20220
17
- hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
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,,