hassl 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {hassl-0.2.1/hassl.egg-info → hassl-0.3.0}/PKG-INFO +53 -10
  2. {hassl-0.2.1 → hassl-0.3.0}/README.md +52 -9
  3. hassl-0.3.0/hassl/__init__.py +1 -0
  4. hassl-0.3.0/hassl/ast/nodes.py +53 -0
  5. hassl-0.3.0/hassl/cli.py +372 -0
  6. hassl-0.3.0/hassl/codegen/generate.py +6 -0
  7. hassl-0.3.0/hassl/codegen/init.py +3 -0
  8. {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/package.py +255 -20
  9. {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/rules_min.py +158 -187
  10. {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/hassl.lark +28 -7
  11. {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/transform.py +152 -70
  12. hassl-0.3.0/hassl/semantics/analyzer.py +305 -0
  13. {hassl-0.2.1 → hassl-0.3.0/hassl.egg-info}/PKG-INFO +53 -10
  14. {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/SOURCES.txt +6 -1
  15. {hassl-0.2.1 → hassl-0.3.0}/pyproject.toml +1 -1
  16. hassl-0.3.0/tests/test_imports_and_schedules.py +257 -0
  17. hassl-0.2.1/hassl/ast/nodes.py +0 -34
  18. hassl-0.2.1/hassl/cli.py +0 -42
  19. hassl-0.2.1/hassl/semantics/__init__.py +0 -0
  20. hassl-0.2.1/hassl/semantics/analyzer.py +0 -145
  21. {hassl-0.2.1 → hassl-0.3.0}/LICENSE +0 -0
  22. {hassl-0.2.1 → hassl-0.3.0}/MANIFEST.in +0 -0
  23. {hassl-0.2.1/hassl → hassl-0.3.0/hassl/ast}/__init__.py +0 -0
  24. {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/__init__.py +0 -0
  25. {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/yaml_emit.py +0 -0
  26. {hassl-0.2.1/hassl/ast → hassl-0.3.0/hassl/parser}/__init__.py +0 -0
  27. {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/loader.py +0 -0
  28. {hassl-0.2.1/hassl/parser → hassl-0.3.0/hassl/semantics}/__init__.py +0 -0
  29. {hassl-0.2.1 → hassl-0.3.0}/hassl/semantics/domains.py +0 -0
  30. {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/dependency_links.txt +0 -0
  31. {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/entry_points.txt +0 -0
  32. {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/requires.txt +0 -0
  33. {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/top_level.txt +0 -0
  34. {hassl-0.2.1 → hassl-0.3.0}/setup.cfg +0 -0
  35. {hassl-0.2.1 → hassl-0.3.0}/tests/test_codegen_sync_basic.py +0 -0
  36. {hassl-0.2.1 → hassl-0.3.0}/tests/test_golden_ir_sync_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hassl
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: HASSL: Home Assistant Simple Scripting Language
5
5
  Home-page: https://github.com/adanowitz/hassl
6
6
  Author: adanowitz
@@ -17,6 +17,8 @@ Dynamic: license-file
17
17
 
18
18
  > **Home Assistant Simple Scripting Language**
19
19
 
20
+ ![Version](https://img.shields.io/badge/version-v0.3.0-blue)
21
+
20
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/).
21
23
 
22
24
  It compiles lightweight `.hassl` scripts into fully functional YAML packages that plug directly into Home Assistant, replacing complex automations with a clean, readable syntax.
@@ -32,12 +34,15 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
32
34
  - **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
33
35
  - **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
34
36
  - **Color temperature in Kelvin** → `light.kelvin = 2700`
37
+ - **Modular packages/imports** → split automations across files with public/private exports (v0.3.0)
35
38
  - **Auto-reload resilience** → schedules re-evaluate automatically on HA restart
36
39
 
37
40
  ---
38
41
 
39
42
  ## 🧰 Example
40
43
 
44
+ ### Basic standalone script
45
+
41
46
  ```hassl
42
47
  alias light = light.wesley_lamp
43
48
  alias motion = binary_sensor.wesley_motion_motion
@@ -64,6 +69,45 @@ Produces a complete Home Assistant package with:
64
69
  - Sync automations for linked devices
65
70
  - Rule-based automations with schedules and `not_by` guards
66
71
 
72
+ ### Using imports across packages
73
+
74
+ ```hassl
75
+ # packages/std/shared.hassl
76
+ package std.shared
77
+
78
+ alias light = light.wesley_lamp
79
+ alias motion = binary_sensor.wesley_motion_motion
80
+ alias lux = sensor.wesley_motion_illuminance
81
+
82
+ schedule wake_hours:
83
+ enable from 08:00 until 19:00;
84
+ ```
85
+
86
+ ```hassl
87
+ # packages/home/landing.hassl
88
+ package home.landing
89
+ import std.shared.*
90
+
91
+ rule wesley_motion_light:
92
+ schedule use wake_hours;
93
+ if (motion && lux < 50)
94
+ then light = on;
95
+ wait (!motion for 10m) light = off
96
+
97
+ rule landing_manual_off:
98
+ if (light == off) not_by any_hassl
99
+ then disable rule wesley_motion_light for 3m
100
+ ```
101
+
102
+ This setup produces:
103
+ - One **shared package** defining reusable aliases and schedules
104
+ - A **landing package** importing and reusing those exports
105
+
106
+ Together, they generate:
107
+ - ✅ Shared schedule sensor (`binary_sensor.hassl_schedule_std_shared_wake_hours_active`)
108
+ - ✅ Cross-package rule automations gated by that schedule
109
+ - ✅ Context-safe helpers and syncs for both packages
110
+
67
111
  ---
68
112
 
69
113
  ## 🏗 Installation
@@ -103,6 +147,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
103
147
  | `scripts_<pkg>.yaml` | Writer scripts with context stamping |
104
148
  | `sync_<pkg>_*.yaml` | Sync automations for each property |
105
149
  | `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
150
+ | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.3.0) |
106
151
 
107
152
  ---
108
153
 
@@ -120,7 +165,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
120
165
 
121
166
  ## 🔒 Loop Safety & Context Tracking
122
167
 
123
- HASSL automatically writes the **parent context ID** into helper entities before performing actions.\
168
+ HASSL automatically writes the **parent context ID** into helper entities before performing actions.
124
169
  This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly, preventing infinite feedback.
125
170
 
126
171
  ---
@@ -129,23 +174,22 @@ This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly
129
174
 
130
175
  All schedules are restart-safe:
131
176
 
132
- - `input_boolean.hassl_schedule_<name>` automatically re-evaluates on startup.
133
- - Triggers are set for both start and end times.
177
+ - `binary_sensor.hassl_schedule_<package>_<name>_active` automatically re-evaluates on startup.
178
+ - Clock and sun-based windows update continuously through HA’s template engine.
134
179
  - Missed events (like mid-day restarts) are recovered automatically.
135
180
 
136
181
  ---
137
182
 
138
183
  ## 📚 Documentation
139
184
 
140
- For full grammar and detailed semantics, see the [HASSL Language Specification](./HASSL_Specification.md).
141
-
142
- For a hands-on guide, check out the [Quickstart](./quickstart.md).
185
+ - [Quickstart Guide](./quickstart_v1.4_2025_v0.3.0.md)
186
+ - [Language Specification](./hassl_language_spec_v1.4_2025_updated_v0.3.0.md)
143
187
 
144
188
  ---
145
189
 
146
190
  ## 🧩 Contributing
147
191
 
148
- Contributions, tests, and ideas welcome!\
192
+ Contributions, tests, and ideas welcome!
149
193
  To run tests locally:
150
194
 
151
195
  ```bash
@@ -158,10 +202,9 @@ Please open pull requests for grammar improvements, new device domains, or sched
158
202
 
159
203
  ## 📄 License
160
204
 
161
- MIT License © 2025\
205
+ MIT License © 2025
162
206
  Created and maintained by [@adanowitz](https://github.com/adanowitz)
163
207
 
164
208
  ---
165
209
 
166
210
  **HASSL** — simple, reliable, human-readable automations for Home Assistant.
167
-
@@ -2,6 +2,8 @@
2
2
 
3
3
  > **Home Assistant Simple Scripting Language**
4
4
 
5
+ ![Version](https://img.shields.io/badge/version-v0.3.0-blue)
6
+
5
7
  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/).
6
8
 
7
9
  It compiles lightweight `.hassl` scripts into fully functional YAML packages that plug directly into Home Assistant, replacing complex automations with a clean, readable syntax.
@@ -17,12 +19,15 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
17
19
  - **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
18
20
  - **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
19
21
  - **Color temperature in Kelvin** → `light.kelvin = 2700`
22
+ - **Modular packages/imports** → split automations across files with public/private exports (v0.3.0)
20
23
  - **Auto-reload resilience** → schedules re-evaluate automatically on HA restart
21
24
 
22
25
  ---
23
26
 
24
27
  ## 🧰 Example
25
28
 
29
+ ### Basic standalone script
30
+
26
31
  ```hassl
27
32
  alias light = light.wesley_lamp
28
33
  alias motion = binary_sensor.wesley_motion_motion
@@ -49,6 +54,45 @@ Produces a complete Home Assistant package with:
49
54
  - Sync automations for linked devices
50
55
  - Rule-based automations with schedules and `not_by` guards
51
56
 
57
+ ### Using imports across packages
58
+
59
+ ```hassl
60
+ # packages/std/shared.hassl
61
+ package std.shared
62
+
63
+ alias light = light.wesley_lamp
64
+ alias motion = binary_sensor.wesley_motion_motion
65
+ alias lux = sensor.wesley_motion_illuminance
66
+
67
+ schedule wake_hours:
68
+ enable from 08:00 until 19:00;
69
+ ```
70
+
71
+ ```hassl
72
+ # packages/home/landing.hassl
73
+ package home.landing
74
+ import std.shared.*
75
+
76
+ rule wesley_motion_light:
77
+ schedule use wake_hours;
78
+ if (motion && lux < 50)
79
+ then light = on;
80
+ wait (!motion for 10m) light = off
81
+
82
+ rule landing_manual_off:
83
+ if (light == off) not_by any_hassl
84
+ then disable rule wesley_motion_light for 3m
85
+ ```
86
+
87
+ This setup produces:
88
+ - One **shared package** defining reusable aliases and schedules
89
+ - A **landing package** importing and reusing those exports
90
+
91
+ Together, they generate:
92
+ - ✅ Shared schedule sensor (`binary_sensor.hassl_schedule_std_shared_wake_hours_active`)
93
+ - ✅ Cross-package rule automations gated by that schedule
94
+ - ✅ Context-safe helpers and syncs for both packages
95
+
52
96
  ---
53
97
 
54
98
  ## 🏗 Installation
@@ -88,6 +132,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
88
132
  | `scripts_<pkg>.yaml` | Writer scripts with context stamping |
89
133
  | `sync_<pkg>_*.yaml` | Sync automations for each property |
90
134
  | `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
135
+ | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.3.0) |
91
136
 
92
137
  ---
93
138
 
@@ -105,7 +150,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
105
150
 
106
151
  ## 🔒 Loop Safety & Context Tracking
107
152
 
108
- HASSL automatically writes the **parent context ID** into helper entities before performing actions.\
153
+ HASSL automatically writes the **parent context ID** into helper entities before performing actions.
109
154
  This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly, preventing infinite feedback.
110
155
 
111
156
  ---
@@ -114,23 +159,22 @@ This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly
114
159
 
115
160
  All schedules are restart-safe:
116
161
 
117
- - `input_boolean.hassl_schedule_<name>` automatically re-evaluates on startup.
118
- - Triggers are set for both start and end times.
162
+ - `binary_sensor.hassl_schedule_<package>_<name>_active` automatically re-evaluates on startup.
163
+ - Clock and sun-based windows update continuously through HA’s template engine.
119
164
  - Missed events (like mid-day restarts) are recovered automatically.
120
165
 
121
166
  ---
122
167
 
123
168
  ## 📚 Documentation
124
169
 
125
- For full grammar and detailed semantics, see the [HASSL Language Specification](./HASSL_Specification.md).
126
-
127
- For a hands-on guide, check out the [Quickstart](./quickstart.md).
170
+ - [Quickstart Guide](./quickstart_v1.4_2025_v0.3.0.md)
171
+ - [Language Specification](./hassl_language_spec_v1.4_2025_updated_v0.3.0.md)
128
172
 
129
173
  ---
130
174
 
131
175
  ## 🧩 Contributing
132
176
 
133
- Contributions, tests, and ideas welcome!\
177
+ Contributions, tests, and ideas welcome!
134
178
  To run tests locally:
135
179
 
136
180
  ```bash
@@ -143,10 +187,9 @@ Please open pull requests for grammar improvements, new device domains, or sched
143
187
 
144
188
  ## 📄 License
145
189
 
146
- MIT License © 2025\
190
+ MIT License © 2025
147
191
  Created and maintained by [@adanowitz](https://github.com/adanowitz)
148
192
 
149
193
  ---
150
194
 
151
195
  **HASSL** — simple, reliable, human-readable automations for Home Assistant.
152
-
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,53 @@
1
+ from dataclasses import dataclass, asdict, field
2
+ from typing import List, Any, Dict, Optional
3
+
4
+ @dataclass
5
+ class Alias:
6
+ name: str
7
+ entity: str
8
+ private: bool = False
9
+
10
+ @dataclass
11
+ class Sync:
12
+ kind: str
13
+ members: List[str]
14
+ name: str
15
+ invert: List[str] = field(default_factory=list)
16
+
17
+ @dataclass
18
+ class IfClause:
19
+ condition: Dict[str, Any]
20
+ actions: List[Dict[str, Any]]
21
+
22
+ @dataclass
23
+ class Schedule:
24
+ name: str
25
+ # raw clauses as produced by the transformer, e.g. {"type":"schedule_clause", ...}
26
+ clauses: List[Dict[str, Any]]
27
+ private: bool = False
28
+
29
+ @dataclass
30
+ class Rule:
31
+ name: str
32
+ # allow schedule dicts
33
+ clauses: List[Any]
34
+
35
+ @dataclass
36
+ class Program:
37
+ statements: List[object]
38
+ package: Optional[str] = None
39
+ # normalized import entries (dicts) from the transformer:
40
+ # {"type":"import","module": "...", "kind": "glob|list|alias", "items":
41
+ #[...], "as": "name"|None}
42
+ imports: List[Dict[str, Any]] = field(default_factory=list)
43
+ def to_dict(self):
44
+ def enc(x):
45
+ if isinstance(x, (Alias, Sync, Rule, IfClause, Schedule)):
46
+ d = asdict(x); d["type"] = x.__class__.__name__; return d
47
+ return x
48
+ return {
49
+ "type": "Program",
50
+ "package": self.package,
51
+ "imports": self.imports,
52
+ "statements": [enc(s) for s in self.statements],
53
+ }
@@ -0,0 +1,372 @@
1
+ import argparse
2
+ import os, json, glob
3
+ from pathlib import Path
4
+ from typing import Dict, Tuple, List
5
+ from .parser.loader import load_grammar_text
6
+ from .parser.transform import HasslTransformer
7
+ from .ast.nodes import Program, Alias, Schedule
8
+ from lark import Lark
9
+ from .semantics import analyzer as sem_analyzer
10
+ from .semantics.analyzer import analyze
11
+ from .codegen.package import emit_package
12
+ from .codegen import generate as codegen_generate
13
+
14
+ def parse_hassl(text: str) -> Program:
15
+ grammar = load_grammar_text()
16
+ parser = Lark(grammar, start="start", parser="lalr", maybe_placeholders=False)
17
+ tree = parser.parse(text)
18
+ program = HasslTransformer().transform(tree)
19
+ return program
20
+
21
+
22
+ def _normalize_module(importing_pkg: str, mod: str) -> str:
23
+ """
24
+ Resolve Python-like relative module notation to an absolute dotted id.
25
+ Examples (importing_pkg='home.addie.automations'):
26
+ '.shared' -> 'home.addie.shared'
27
+ '..shared' -> 'home.shared'
28
+ 'std.shared' (absolute) stays 'std.shared'
29
+ """
30
+ if not mod:
31
+ return mod
32
+ if not mod.startswith("."):
33
+ return mod # already absolute
34
+ # Count leading dots
35
+ i = 0
36
+ while i < len(mod) and mod[i] == ".":
37
+ i += 1
38
+ rel = mod[i:] # tail after dots (may be '')
39
+ base_parts = (importing_pkg or "").split(".")
40
+ # Pop one level per dot
41
+ up = i - 1 # '.x' means stay at same depth + replace last segment -> up=0
42
+ if up > 0 and up <= len(base_parts):
43
+ base_parts = base_parts[:len(base_parts) - up]
44
+ elif up > len(base_parts):
45
+ base_parts = []
46
+ if rel:
47
+ return ".".join([p for p in base_parts if p] + [rel])
48
+ return ".".join([p for p in base_parts if p])
49
+
50
+ def _derive_package_name(prog: Program, src_path: Path, module_root: Path | None) -> str:
51
+ """
52
+ If the source did not declare `package`, derive one from the path:
53
+ - If module_root is given and src_path is under it: use relative path (dots)
54
+ - Else: use file stem
55
+ """
56
+ if getattr(prog, "package", None):
57
+ return prog.package # declared
58
+ if module_root:
59
+ try:
60
+ rel = src_path.resolve().relative_to(module_root.resolve())
61
+ parts = list(rel.with_suffix("").parts)
62
+ if parts:
63
+ return ".".join(parts)
64
+ except Exception:
65
+ pass
66
+ return src_path.stem
67
+
68
+ def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str], object]:
69
+ """
70
+ Build (pkg, kind, name) -> node for public alias/schedule in a single Program.
71
+ Accepts both Schedule nodes and transformer dicts {"type":"schedule_decl",...}.
72
+ """
73
+ out: Dict[Tuple[str,str,str], object] = {}
74
+ # Aliases
75
+ for s in prog.statements:
76
+ if isinstance(s, Alias):
77
+ if not getattr(s, "private", False):
78
+ out[(pkg, "alias", s.name)] = s
79
+ # Schedules (either dicts from transformer or Schedule nodes)
80
+ for s in prog.statements:
81
+ if isinstance(s, Schedule):
82
+ if not getattr(s, "private", False):
83
+ out[(pkg, "schedule", s.name)] = s
84
+ elif isinstance(s, dict) and s.get("type") == "schedule_decl" and not s.get("private", False):
85
+ name = s.get("name")
86
+ if isinstance(name, str) and name.strip():
87
+ out[(pkg, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []) or [], private=False)
88
+ return out
89
+
90
+ def _scan_hassl_files(path: Path) -> List[Path]:
91
+ if path.is_file():
92
+ return [path]
93
+ return [Path(p) for p in glob.glob(str(path / "**" / "*.hassl"), recursive=True)]
94
+
95
+ def _module_to_path(module_root: Path, module: str) -> Path:
96
+ return (module_root / Path(module.replace(".", "/"))).with_suffix(".hassl")
97
+
98
+ def _ensure_imports_loaded(programs, module_root: Path):
99
+ """If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
100
+ # programs: List[tuple[Path, Program, str]]
101
+ known_pkgs = {pkg for _, _, pkg in programs}
102
+ added = True
103
+ while added:
104
+ added = False
105
+ for _, prog, importer_pkg in list(programs):
106
+ for imp in getattr(prog, "imports", []) or []:
107
+ if not isinstance(imp, dict) or imp.get("type") != "import":
108
+ continue
109
+ raw_mod = imp.get("module", "")
110
+ if not raw_mod:
111
+ continue
112
+ # resolve relative notation against the importing package
113
+ abs_mod = _normalize_module(importer_pkg, raw_mod)
114
+ if abs_mod in known_pkgs:
115
+ continue
116
+ if not module_root:
117
+ continue
118
+ 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:
132
+ print(f"[hasslc] Autoload candidate MISS for '{abs_mod}': {candidate}")
133
+
134
+ def main():
135
+ print("[hasslc] Using CLI file:", __file__)
136
+ ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
137
+ ap.add_argument("input", help="Input .hassl file OR directory")
138
+ ap.add_argument("-o", "--out", default="./packages/out", help="Output directory root for HA package(s)")
139
+ ap.add_argument("--module-root", default=None, help="Optional root to derive package names from paths")
140
+ args = ap.parse_args()
141
+
142
+ in_path = Path(args.input)
143
+ out_root = Path(args.out)
144
+ module_root = Path(args.module_root).resolve() if args.module_root else None
145
+
146
+ src_files = _scan_hassl_files(in_path)
147
+ if not src_files:
148
+ raise SystemExit(f"[hasslc] No .hassl files found in {in_path}")
149
+
150
+ # Pass 0: parse all and assign/derive package names
151
+ programs: List[tuple[Path, Program, str]] = []
152
+ for p in src_files:
153
+ with open(p, "r", encoding="utf-8") as f:
154
+ text = f.read()
155
+ prog = parse_hassl(text)
156
+ pkg_name = _derive_package_name(prog, p, module_root)
157
+ try:
158
+ prog.package = pkg_name
159
+ except Exception:
160
+ pass
161
+ programs.append((p, prog, pkg_name))
162
+
163
+ # auto-load any missing imports from --module_root
164
+ _ensure_imports_loaded(programs, module_root)
165
+
166
+ # Pass 1: collect public exports across all programs
167
+ GLOBAL_EXPORTS: Dict[Tuple[str,str,str], object] = {}
168
+ for path, prog, pkg in programs:
169
+ GLOBAL_EXPORTS.update(_collect_public_exports(prog, pkg))
170
+
171
+ # publish global exports to analyzer
172
+ sem_analyzer.GLOBAL_EXPORTS = GLOBAL_EXPORTS
173
+
174
+ # Pass 2: analyze each program with global view
175
+ os.makedirs(out_root, exist_ok=True)
176
+ all_ir = []
177
+ for path, prog, pkg in programs:
178
+ print(f"[hasslc] Parsing {path} (package: {pkg})")
179
+ print("[hasslc] AST:", json.dumps(prog.to_dict(), indent=2))
180
+ ir = analyze(prog)
181
+ print("[hasslc] IR:", json.dumps(ir.to_dict(), indent=2))
182
+ all_ir.append((pkg, ir))
183
+
184
+ # Emit: per package subdir
185
+ for pkg, ir in all_ir:
186
+ # One-level output: flatten dotted package id into a single directory name
187
+ # e.g., home.addie.automations -> packages/out/home_addie_automations/
188
+ pkg_dir = out_root / pkg.replace(".", "_")
189
+ print(f"[hasslc] Output directory (flat): {pkg_dir}")
190
+ os.makedirs(pkg_dir, exist_ok=True)
191
+ ir_dict = ir.to_dict() if hasattr(ir, "to_dict") else ir
192
+ codegen_generate(ir_dict, str(pkg_dir))
193
+ emit_package(ir, str(pkg_dir))
194
+ with open(pkg_dir / "DEBUG_ir.json", "w", encoding="utf-8") as dbg:
195
+ dbg.write(json.dumps(ir.to_dict(), indent=2))
196
+ print(f"[hasslc] Package written to {pkg_dir}")
197
+
198
+ # Also drop a cross-project export table for debugging
199
+ 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()}
368
+ json.dump(printable, fp, indent=2)
369
+ print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
370
+
371
+ if __name__ == "__main__":
372
+ main()