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.
- {hassl-0.2.1/hassl.egg-info → hassl-0.3.0}/PKG-INFO +53 -10
- {hassl-0.2.1 → hassl-0.3.0}/README.md +52 -9
- hassl-0.3.0/hassl/__init__.py +1 -0
- hassl-0.3.0/hassl/ast/nodes.py +53 -0
- hassl-0.3.0/hassl/cli.py +372 -0
- hassl-0.3.0/hassl/codegen/generate.py +6 -0
- hassl-0.3.0/hassl/codegen/init.py +3 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/package.py +255 -20
- {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/rules_min.py +158 -187
- {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/hassl.lark +28 -7
- {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/transform.py +152 -70
- hassl-0.3.0/hassl/semantics/analyzer.py +305 -0
- {hassl-0.2.1 → hassl-0.3.0/hassl.egg-info}/PKG-INFO +53 -10
- {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/SOURCES.txt +6 -1
- {hassl-0.2.1 → hassl-0.3.0}/pyproject.toml +1 -1
- hassl-0.3.0/tests/test_imports_and_schedules.py +257 -0
- hassl-0.2.1/hassl/ast/nodes.py +0 -34
- hassl-0.2.1/hassl/cli.py +0 -42
- hassl-0.2.1/hassl/semantics/__init__.py +0 -0
- hassl-0.2.1/hassl/semantics/analyzer.py +0 -145
- {hassl-0.2.1 → hassl-0.3.0}/LICENSE +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/MANIFEST.in +0 -0
- {hassl-0.2.1/hassl → hassl-0.3.0/hassl/ast}/__init__.py +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/__init__.py +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl/codegen/yaml_emit.py +0 -0
- {hassl-0.2.1/hassl/ast → hassl-0.3.0/hassl/parser}/__init__.py +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl/parser/loader.py +0 -0
- {hassl-0.2.1/hassl/parser → hassl-0.3.0/hassl/semantics}/__init__.py +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl/semantics/domains.py +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/dependency_links.txt +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/entry_points.txt +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/requires.txt +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/hassl.egg-info/top_level.txt +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/setup.cfg +0 -0
- {hassl-0.2.1 → hassl-0.3.0}/tests/test_codegen_sync_basic.py +0 -0
- {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.
|
|
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
|
+

|
|
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
|
-
- `
|
|
133
|
-
-
|
|
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
|
-
|
|
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
|
+

|
|
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
|
-
- `
|
|
118
|
-
-
|
|
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
|
-
|
|
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
|
+
}
|
hassl-0.3.0/hassl/cli.py
ADDED
|
@@ -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()
|