ewt-gen 0.1.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.
@@ -0,0 +1,39 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - name: Install uv
14
+ uses: astral-sh/setup-uv@v4
15
+
16
+ - name: Build package
17
+ run: uv build
18
+
19
+ - name: Upload dist
20
+ uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - name: Download dist
33
+ uses: actions/download-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ .esphome/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
ewt_gen-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: ewt-gen
3
+ Version: 0.1.0
4
+ Summary: Generate static websites for ESPHome firmware distribution using ESP Web Tools
5
+ Project-URL: Homepage, https://github.com/esphome/ewt-gen
6
+ Project-URL: Repository, https://github.com/esphome/ewt-gen
7
+ Project-URL: Issues, https://github.com/esphome/ewt-gen/issues
8
+ License-Expression: Apache-2.0
9
+ Keywords: esp32,esp8266,esphome,firmware,web-tools
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Home Automation
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: click>=8.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ewt-gen
26
+
27
+ Generate static websites for ESPHome firmware distribution using ESP Web Tools.
28
+
29
+ ## Quick Start
30
+
31
+ ```bash
32
+ # From a local file
33
+ uvx ewt-gen config.yaml
34
+
35
+ # From a URL
36
+ uvx ewt-gen https://github.com/esphome/firmware/blob/main/esphome-web/esp32.factory.yaml
37
+ ```
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ # Run directly without installing (recommended)
43
+ uvx ewt-gen config.yaml
44
+
45
+ # Or install globally
46
+ uv tool install ewt-gen
47
+ ewt-gen config.yaml
48
+
49
+ # Or with pip
50
+ pip install ewt-gen
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ # From a local file
57
+ uvx ewt-gen config.yaml
58
+
59
+ # From a GitHub file URL
60
+ uvx ewt-gen https://github.com/user/repo/blob/main/config.yaml
61
+
62
+ # From a GitHub Gist
63
+ uvx ewt-gen https://gist.github.com/user/abc123
64
+
65
+ # From any URL
66
+ uvx ewt-gen https://example.com/config.yaml
67
+ ```
68
+
69
+ ### Options
70
+
71
+ ```
72
+ ewt-gen [OPTIONS] YAML_SOURCE
73
+
74
+ Options:
75
+ --version Show version
76
+ --skip-compile Skip ESPHome compilation (use existing firmware)
77
+ -f, --firmware PATH Path to firmware binary
78
+ -c, --chip-family [esp32|esp32-c3|esp32-s2|esp32-s3|esp8266]
79
+ Chip family (auto-detected from YAML)
80
+ -o, --output PATH Output directory (defaults to YAML filename)
81
+ -t, --title TEXT Page title (defaults to name from YAML)
82
+ --pre-release Use pre-release ESPHome version via uvx
83
+ --help Show help
84
+ ```
85
+
86
+ ### Examples
87
+
88
+ ```bash
89
+ # Basic usage - compiles and generates site
90
+ ewt-gen my-device.yaml
91
+
92
+ # Custom output directory and title
93
+ ewt-gen my-device.yaml -o ./dist -t "My Smart Device"
94
+
95
+ # Use pre-release ESPHome
96
+ ewt-gen my-device.yaml --pre-release
97
+
98
+ # Skip compilation, use existing firmware
99
+ ewt-gen my-device.yaml --skip-compile -f firmware.bin
100
+ ```
101
+
102
+ ## Generated Site
103
+
104
+ The tool generates a static website containing:
105
+
106
+ - **ESP Web Tools install button** - One-click firmware installation (requires HTTPS)
107
+ - **Firmware download** - Direct download of the compiled binary
108
+ - **YAML download** - Original ESPHome configuration
109
+ - **Manual installation instructions** - For non-HTTPS contexts, with link to web.esphome.io
110
+
111
+ ### HTTPS Requirement
112
+
113
+ Browser-based installation using ESP Web Tools requires a secure context (HTTPS or localhost). When served over HTTP, the page automatically shows manual installation instructions instead.
114
+
115
+ ## ESPHome Detection
116
+
117
+ The tool automatically:
118
+
119
+ - Detects chip family from the YAML configuration
120
+ - Finds compiled firmware in `.esphome/build/` directory
121
+ - Uses local `esphome` if available, falls back to `uvx esphome`
122
+
123
+ ## License
124
+
125
+ Apache 2.0
126
+
127
+ ## Credits
128
+
129
+ - [ESP Web Tools](https://esphome.github.io/esp-web-tools/) - Browser-based firmware installation
130
+ - [ESPHome](https://esphome.io) - Easy ESP8266/ESP32 firmware configuration
131
+ - [Open Home Foundation](https://www.openhomefoundation.org/)
@@ -0,0 +1,107 @@
1
+ # ewt-gen
2
+
3
+ Generate static websites for ESPHome firmware distribution using ESP Web Tools.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # From a local file
9
+ uvx ewt-gen config.yaml
10
+
11
+ # From a URL
12
+ uvx ewt-gen https://github.com/esphome/firmware/blob/main/esphome-web/esp32.factory.yaml
13
+ ```
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ # Run directly without installing (recommended)
19
+ uvx ewt-gen config.yaml
20
+
21
+ # Or install globally
22
+ uv tool install ewt-gen
23
+ ewt-gen config.yaml
24
+
25
+ # Or with pip
26
+ pip install ewt-gen
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # From a local file
33
+ uvx ewt-gen config.yaml
34
+
35
+ # From a GitHub file URL
36
+ uvx ewt-gen https://github.com/user/repo/blob/main/config.yaml
37
+
38
+ # From a GitHub Gist
39
+ uvx ewt-gen https://gist.github.com/user/abc123
40
+
41
+ # From any URL
42
+ uvx ewt-gen https://example.com/config.yaml
43
+ ```
44
+
45
+ ### Options
46
+
47
+ ```
48
+ ewt-gen [OPTIONS] YAML_SOURCE
49
+
50
+ Options:
51
+ --version Show version
52
+ --skip-compile Skip ESPHome compilation (use existing firmware)
53
+ -f, --firmware PATH Path to firmware binary
54
+ -c, --chip-family [esp32|esp32-c3|esp32-s2|esp32-s3|esp8266]
55
+ Chip family (auto-detected from YAML)
56
+ -o, --output PATH Output directory (defaults to YAML filename)
57
+ -t, --title TEXT Page title (defaults to name from YAML)
58
+ --pre-release Use pre-release ESPHome version via uvx
59
+ --help Show help
60
+ ```
61
+
62
+ ### Examples
63
+
64
+ ```bash
65
+ # Basic usage - compiles and generates site
66
+ ewt-gen my-device.yaml
67
+
68
+ # Custom output directory and title
69
+ ewt-gen my-device.yaml -o ./dist -t "My Smart Device"
70
+
71
+ # Use pre-release ESPHome
72
+ ewt-gen my-device.yaml --pre-release
73
+
74
+ # Skip compilation, use existing firmware
75
+ ewt-gen my-device.yaml --skip-compile -f firmware.bin
76
+ ```
77
+
78
+ ## Generated Site
79
+
80
+ The tool generates a static website containing:
81
+
82
+ - **ESP Web Tools install button** - One-click firmware installation (requires HTTPS)
83
+ - **Firmware download** - Direct download of the compiled binary
84
+ - **YAML download** - Original ESPHome configuration
85
+ - **Manual installation instructions** - For non-HTTPS contexts, with link to web.esphome.io
86
+
87
+ ### HTTPS Requirement
88
+
89
+ Browser-based installation using ESP Web Tools requires a secure context (HTTPS or localhost). When served over HTTP, the page automatically shows manual installation instructions instead.
90
+
91
+ ## ESPHome Detection
92
+
93
+ The tool automatically:
94
+
95
+ - Detects chip family from the YAML configuration
96
+ - Finds compiled firmware in `.esphome/build/` directory
97
+ - Uses local `esphome` if available, falls back to `uvx esphome`
98
+
99
+ ## License
100
+
101
+ Apache 2.0
102
+
103
+ ## Credits
104
+
105
+ - [ESP Web Tools](https://esphome.github.io/esp-web-tools/) - Browser-based firmware installation
106
+ - [ESPHome](https://esphome.io) - Easy ESP8266/ESP32 firmware configuration
107
+ - [Open Home Foundation](https://www.openhomefoundation.org/)
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "ewt-gen"
3
+ version = "0.1.0"
4
+ description = "Generate static websites for ESPHome firmware distribution using ESP Web Tools"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ keywords = ["esphome", "esp32", "esp8266", "firmware", "web-tools"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Home Automation",
20
+ ]
21
+ dependencies = [
22
+ "click>=8.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/esphome/ewt-gen"
28
+ Repository = "https://github.com/esphome/ewt-gen"
29
+ Issues = "https://github.com/esphome/ewt-gen/issues"
30
+
31
+ [project.scripts]
32
+ ewt-gen = "ewt.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/ewt"]
@@ -0,0 +1,3 @@
1
+ """EWT - ESP Web Tools static site generator for ESPHome firmware distribution."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,348 @@
1
+ """CLI interface for EWT."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ import click
12
+ import yaml
13
+
14
+ from ewt.generator import generate_site
15
+
16
+
17
+ @click.command()
18
+ @click.version_option()
19
+ @click.argument("yaml_source")
20
+ @click.option(
21
+ "--skip-compile",
22
+ is_flag=True,
23
+ help="Skip ESPHome compilation (use existing firmware).",
24
+ )
25
+ @click.option(
26
+ "--firmware",
27
+ "-f",
28
+ type=click.Path(exists=True, path_type=Path),
29
+ help="Path to firmware binary. If not specified, uses ESPHome build output.",
30
+ )
31
+ @click.option(
32
+ "--chip-family",
33
+ "-c",
34
+ type=click.Choice(
35
+ ["ESP32", "ESP32-C3", "ESP32-S2", "ESP32-S3", "ESP8266"],
36
+ case_sensitive=False,
37
+ ),
38
+ help="Chip family. Auto-detected from YAML if not specified.",
39
+ )
40
+ @click.option(
41
+ "--output",
42
+ "-o",
43
+ type=click.Path(path_type=Path),
44
+ help="Output directory. Defaults to YAML filename without extension.",
45
+ )
46
+ @click.option(
47
+ "--title",
48
+ "-t",
49
+ help="Page title. Defaults to name from YAML file.",
50
+ )
51
+ @click.option(
52
+ "--pre-release",
53
+ is_flag=True,
54
+ help="Use pre-release ESPHome version (uvx only, forces refresh).",
55
+ )
56
+ def main(
57
+ yaml_source: str,
58
+ skip_compile: bool,
59
+ firmware: Path | None,
60
+ chip_family: str | None,
61
+ output: Path | None,
62
+ title: str | None,
63
+ pre_release: bool,
64
+ ):
65
+ """Generate a static website for firmware distribution.
66
+
67
+ YAML_SOURCE is the ESPHome configuration file path or URL.
68
+ """
69
+ yaml_file, was_downloaded = resolve_yaml_source(yaml_source)
70
+
71
+ # Load YAML to get configuration info (with ESPHome tag support)
72
+ with open(yaml_file) as f:
73
+ config = load_esphome_yaml(f)
74
+
75
+ # Get substitutions for variable expansion
76
+ substitutions = config.get("substitutions", {})
77
+
78
+ def expand_substitutions(value: str) -> str:
79
+ """Expand ${var} substitutions in a string."""
80
+ if not isinstance(value, str):
81
+ return value
82
+ for key, sub_value in substitutions.items():
83
+ value = value.replace(f"${{{key}}}", str(sub_value))
84
+ return value
85
+
86
+ # Determine project name
87
+ esphome_config = config.get("esphome", {})
88
+ project_name = expand_substitutions(esphome_config.get("name", "")) or yaml_file.stem
89
+
90
+ # Determine title
91
+ if not title:
92
+ title = expand_substitutions(esphome_config.get("friendly_name", "")) or project_name
93
+
94
+ # Compile with ESPHome if needed
95
+ if not skip_compile and firmware is None:
96
+ click.echo(f"Compiling {yaml_file.name} with ESPHome...")
97
+ compile_with_esphome(yaml_file, pre_release=pre_release)
98
+
99
+ # Find firmware binary
100
+ if firmware is None:
101
+ firmware = find_firmware(yaml_file, project_name)
102
+
103
+ if firmware is None:
104
+ raise click.ClickException(
105
+ f"Could not find firmware binary. Please specify with --firmware option.\n"
106
+ f"Looked for: {yaml_file.stem}.bin, .esphome/build/{project_name}/.pioenvs/*/firmware.bin"
107
+ )
108
+
109
+ firmware = firmware.resolve()
110
+
111
+ # Determine chip family
112
+ if chip_family is None:
113
+ chip_family = detect_chip_family(config)
114
+
115
+ if chip_family is None:
116
+ raise click.ClickException(
117
+ "Could not detect chip family from YAML. Please specify with --chip-family option."
118
+ )
119
+
120
+ # Normalize chip family
121
+ chip_family = normalize_chip_family(chip_family)
122
+
123
+ # Determine output directory
124
+ if output is None:
125
+ output = Path.cwd() / yaml_file.stem
126
+
127
+ output = output.resolve()
128
+
129
+ click.echo(f"Generating static site for {project_name}")
130
+ click.echo(f" YAML: {yaml_file}")
131
+ click.echo(f" Firmware: {firmware}")
132
+ click.echo(f" Chip: {chip_family}")
133
+ click.echo(f" Output: {output}")
134
+
135
+ generate_site(
136
+ output_dir=output,
137
+ yaml_file=yaml_file,
138
+ firmware_file=firmware,
139
+ chip_family=chip_family,
140
+ title=title,
141
+ )
142
+
143
+ # Clean up downloaded YAML (it's already copied to output)
144
+ if was_downloaded:
145
+ yaml_file.unlink()
146
+
147
+ click.echo(f"\nStatic site generated at: {output}")
148
+ click.echo("Serve with any static file server (must be HTTPS for ESP Web Tools)")
149
+
150
+
151
+ def load_esphome_yaml(stream):
152
+ """Load ESPHome YAML with support for custom tags like !lambda, !secret, etc."""
153
+ class ESPHomeLoader(yaml.SafeLoader):
154
+ pass
155
+
156
+ # Handle all unknown tags by returning the value as-is
157
+ def constructor_undefined(loader, tag_suffix, node):
158
+ if isinstance(node, yaml.ScalarNode):
159
+ return loader.construct_scalar(node)
160
+ if isinstance(node, yaml.SequenceNode):
161
+ return loader.construct_sequence(node)
162
+ if isinstance(node, yaml.MappingNode):
163
+ return loader.construct_mapping(node)
164
+
165
+ ESPHomeLoader.add_multi_constructor("!", constructor_undefined)
166
+
167
+ return yaml.load(stream, Loader=ESPHomeLoader)
168
+
169
+
170
+ def resolve_yaml_source(source: str) -> tuple[Path, bool]:
171
+ """Resolve a YAML source (file path or URL) to a local file path.
172
+
173
+ Returns (path, was_downloaded) tuple.
174
+ """
175
+ # Check if it's a URL
176
+ if source.startswith(("http://", "https://")):
177
+ return download_yaml(source), True
178
+
179
+ # It's a local file path
180
+ path = Path(source)
181
+ if not path.exists():
182
+ raise click.ClickException(f"File not found: {source}")
183
+ return path.resolve(), False
184
+
185
+
186
+ def download_yaml(url: str) -> Path:
187
+ """Download YAML from a URL and save to a temporary file."""
188
+ # Convert GitHub blob URLs to raw URLs
189
+ url = convert_to_raw_url(url)
190
+
191
+ click.echo(f"Downloading {url}...")
192
+
193
+ try:
194
+ req = urllib.request.Request(url, headers={"User-Agent": "ewt"})
195
+ with urllib.request.urlopen(req) as response:
196
+ content = response.read().decode("utf-8")
197
+ except urllib.error.URLError as e:
198
+ raise click.ClickException(f"Failed to download {url}: {e}")
199
+
200
+ # Extract filename from URL
201
+ parsed = urlparse(url)
202
+ filename = Path(parsed.path).name
203
+ if not filename.endswith((".yaml", ".yml")):
204
+ filename = "config.yaml"
205
+
206
+ # Save to temp file in current directory (so .esphome is created here)
207
+ yaml_file = Path.cwd() / filename
208
+ yaml_file.write_text(content)
209
+
210
+ return yaml_file
211
+
212
+
213
+ def convert_to_raw_url(url: str) -> str:
214
+ """Convert GitHub/Gist URLs to raw content URLs."""
215
+ # GitHub blob URL: https://github.com/user/repo/blob/branch/path/file.yaml
216
+ # -> https://raw.githubusercontent.com/user/repo/branch/path/file.yaml
217
+ github_blob = re.match(
218
+ r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)", url
219
+ )
220
+ if github_blob:
221
+ user, repo, branch, path = github_blob.groups()
222
+ return f"https://raw.githubusercontent.com/{user}/{repo}/{branch}/{path}"
223
+
224
+ # GitHub Gist URL: https://gist.github.com/user/gist_id
225
+ # or https://gist.github.com/user/gist_id#file-filename-yaml
226
+ # -> https://gist.githubusercontent.com/user/gist_id/raw/filename.yaml
227
+ gist_match = re.match(
228
+ r"https://gist\.github\.com/([^/]+)/([^/#]+)(?:#file-(.+))?", url
229
+ )
230
+ if gist_match:
231
+ user, gist_id, file_fragment = gist_match.groups()
232
+ if file_fragment:
233
+ # Convert file-name-yaml to name.yaml
234
+ filename = file_fragment.replace("-", ".")
235
+ # Fix double dots from extension
236
+ filename = re.sub(r"\.([^.]+)$", lambda m: "." + m.group(1), filename)
237
+ return f"https://gist.githubusercontent.com/{user}/{gist_id}/raw/{filename}"
238
+ return f"https://gist.githubusercontent.com/{user}/{gist_id}/raw"
239
+
240
+ # Already a raw URL or other URL, return as-is
241
+ return url
242
+
243
+
244
+ def compile_with_esphome(yaml_file: Path, *, pre_release: bool = False) -> None:
245
+ """Compile the ESPHome configuration."""
246
+ cwd = yaml_file.parent
247
+
248
+ # If pre-release requested, must use uvx
249
+ if pre_release:
250
+ if not shutil.which("uvx"):
251
+ raise click.ClickException(
252
+ "uvx not found. Please install uv to use --pre-release."
253
+ )
254
+ cmd = ["uvx", "--prerelease", "allow", "--refresh", "esphome", "compile", str(yaml_file)]
255
+ else:
256
+ # Try local esphome first, fall back to uvx
257
+ if shutil.which("esphome"):
258
+ cmd = ["esphome", "compile", str(yaml_file)]
259
+ elif shutil.which("uvx"):
260
+ cmd = ["uvx", "esphome", "compile", str(yaml_file)]
261
+ else:
262
+ raise click.ClickException(
263
+ "ESPHome not found. Please install ESPHome or uv:\n"
264
+ " pip install esphome\n"
265
+ "Or use --skip-compile with --firmware to provide a pre-built binary."
266
+ )
267
+
268
+ result = subprocess.run(cmd, cwd=cwd)
269
+ if result.returncode != 0:
270
+ raise click.ClickException(
271
+ f"ESPHome compilation failed with exit code {result.returncode}"
272
+ )
273
+
274
+
275
+ def find_firmware(yaml_file: Path, project_name: str) -> Path | None:
276
+ """Try to find the firmware binary for the given YAML file."""
277
+ yaml_dir = yaml_file.parent
278
+
279
+ # Try same name with .bin extension
280
+ bin_file = yaml_dir / f"{yaml_file.stem}.bin"
281
+ if bin_file.exists():
282
+ return bin_file
283
+
284
+ # Try ESPHome build directory
285
+ esphome_build_dir = yaml_dir / ".esphome" / "build" / project_name / ".pioenvs"
286
+ if esphome_build_dir.exists():
287
+ # Look for firmware.bin in any subdirectory
288
+ for subdir in esphome_build_dir.iterdir():
289
+ if subdir.is_dir():
290
+ fw = subdir / "firmware.bin"
291
+ if fw.exists():
292
+ return fw
293
+
294
+ return None
295
+
296
+
297
+ def detect_chip_family(config: dict) -> str | None:
298
+ """Try to detect chip family from ESPHome config."""
299
+ # Check for esp32 platform
300
+ if "esp32" in config:
301
+ esp32_config = config["esp32"]
302
+ board = esp32_config.get("board", "")
303
+ variant = esp32_config.get("variant", "").upper()
304
+
305
+ # Check variant first
306
+ if variant:
307
+ if variant in ("ESP32C3", "ESP32-C3"):
308
+ return "ESP32-C3"
309
+ if variant in ("ESP32S2", "ESP32-S2"):
310
+ return "ESP32-S2"
311
+ if variant in ("ESP32S3", "ESP32-S3"):
312
+ return "ESP32-S3"
313
+
314
+ # Check board names for variants
315
+ board_lower = board.lower()
316
+ if "c3" in board_lower:
317
+ return "ESP32-C3"
318
+ if "s2" in board_lower:
319
+ return "ESP32-S2"
320
+ if "s3" in board_lower:
321
+ return "ESP32-S3"
322
+
323
+ return "ESP32"
324
+
325
+ # Check for esp8266 platform
326
+ if "esp8266" in config:
327
+ return "ESP8266"
328
+
329
+ return None
330
+
331
+
332
+ def normalize_chip_family(chip_family: str) -> str:
333
+ """Normalize chip family string."""
334
+ mapping = {
335
+ "esp32": "ESP32",
336
+ "esp32c3": "ESP32-C3",
337
+ "esp32-c3": "ESP32-C3",
338
+ "esp32s2": "ESP32-S2",
339
+ "esp32-s2": "ESP32-S2",
340
+ "esp32s3": "ESP32-S3",
341
+ "esp32-s3": "ESP32-S3",
342
+ "esp8266": "ESP8266",
343
+ }
344
+ return mapping.get(chip_family.lower(), chip_family.upper())
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()
@@ -0,0 +1,74 @@
1
+ """Static site generator for ESP Web Tools."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from datetime import datetime, timezone
7
+ from importlib import resources
8
+ from pathlib import Path
9
+
10
+
11
+ def generate_site(
12
+ output_dir: Path,
13
+ yaml_file: Path,
14
+ firmware_file: Path,
15
+ chip_family: str,
16
+ title: str,
17
+ ):
18
+ """Generate a static website for firmware distribution."""
19
+ # Create output directory
20
+ output_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ # Copy files
23
+ yaml_dest = output_dir / yaml_file.name
24
+ firmware_dest = output_dir / "firmware.bin"
25
+
26
+ shutil.copy(yaml_file, yaml_dest)
27
+ shutil.copy(firmware_file, firmware_dest)
28
+
29
+ # Generate manifest.json
30
+ manifest = generate_manifest(
31
+ name=title,
32
+ chip_family=chip_family,
33
+ )
34
+ manifest_path = output_dir / "manifest.json"
35
+ with open(manifest_path, "w") as f:
36
+ json.dump(manifest, f, indent=2)
37
+
38
+ # Generate index.html from template
39
+ build_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
40
+ html = render_template(
41
+ "index.html",
42
+ title=title,
43
+ yaml_filename=yaml_file.name,
44
+ chip_family=chip_family,
45
+ build_date=build_date,
46
+ )
47
+ html_path = output_dir / "index.html"
48
+ with open(html_path, "w") as f:
49
+ f.write(html)
50
+
51
+
52
+ def generate_manifest(name: str, chip_family: str) -> dict:
53
+ """Generate the ESP Web Tools manifest."""
54
+ return {
55
+ "name": name,
56
+ "builds": [
57
+ {
58
+ "chipFamily": chip_family,
59
+ "parts": [{"path": "firmware.bin", "offset": 0}],
60
+ }
61
+ ],
62
+ }
63
+
64
+
65
+ def render_template(template_name: str, **context) -> str:
66
+ """Render a template with the given context using simple string substitution."""
67
+ template_content = resources.files("ewt.templates").joinpath(template_name).read_text()
68
+
69
+ # Simple template rendering: replace {{ variable }} with values
70
+ def replace_var(match):
71
+ var_name = match.group(1).strip()
72
+ return str(context.get(var_name, match.group(0)))
73
+
74
+ return re.sub(r"\{\{\s*(\w+)\s*\}\}", replace_var, template_content)
@@ -0,0 +1 @@
1
+ """HTML templates for EWT."""
@@ -0,0 +1,223 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ title }}</title>
7
+ <script
8
+ type="module"
9
+ src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"
10
+ ></script>
11
+ <style>
12
+ :root {
13
+ --primary-color: #0366d6;
14
+ --bg-color: #ffffff;
15
+ --text-color: #24292e;
16
+ --border-color: #e1e4e8;
17
+ --card-bg: #f6f8fa;
18
+ --warning-bg: #fff8e6;
19
+ --warning-border: #f0c36d;
20
+ }
21
+
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --primary-color: #58a6ff;
25
+ --bg-color: #0d1117;
26
+ --text-color: #c9d1d9;
27
+ --border-color: #30363d;
28
+ --card-bg: #161b22;
29
+ --warning-bg: #3d2e00;
30
+ --warning-border: #6e5a1f;
31
+ }
32
+ }
33
+
34
+ * {
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ body {
39
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
40
+ max-width: 800px;
41
+ margin: 0 auto;
42
+ padding: 2rem;
43
+ background: var(--bg-color);
44
+ color: var(--text-color);
45
+ line-height: 1.6;
46
+ }
47
+
48
+ h1 {
49
+ border-bottom: 1px solid var(--border-color);
50
+ padding-bottom: 0.5rem;
51
+ }
52
+
53
+ h2 {
54
+ margin-top: 2rem;
55
+ }
56
+
57
+ .card {
58
+ background: var(--card-bg);
59
+ border: 1px solid var(--border-color);
60
+ border-radius: 8px;
61
+ padding: 1.5rem;
62
+ margin: 1rem 0;
63
+ }
64
+
65
+ .install-section {
66
+ text-align: center;
67
+ padding: 2rem;
68
+ }
69
+
70
+ esp-web-install-button {
71
+ --esp-tools-button-color: var(--primary-color);
72
+ }
73
+
74
+ a {
75
+ color: var(--primary-color);
76
+ }
77
+
78
+ .download-link {
79
+ display: inline-block;
80
+ padding: 0.5rem 1rem;
81
+ border: 1px solid var(--border-color);
82
+ border-radius: 4px;
83
+ text-decoration: none;
84
+ margin: 0.5rem 0.5rem 0.5rem 0;
85
+ }
86
+
87
+ .download-link:hover {
88
+ background: var(--card-bg);
89
+ }
90
+
91
+ code {
92
+ background: var(--card-bg);
93
+ padding: 0.2rem 0.4rem;
94
+ border-radius: 4px;
95
+ font-size: 0.9em;
96
+ }
97
+
98
+ .chip-badge {
99
+ display: inline-block;
100
+ background: var(--primary-color);
101
+ color: white;
102
+ padding: 0.2rem 0.6rem;
103
+ border-radius: 4px;
104
+ font-size: 0.85rem;
105
+ margin-left: 0.5rem;
106
+ }
107
+
108
+ ol {
109
+ padding-left: 1.5rem;
110
+ }
111
+
112
+ li {
113
+ margin: 0.5rem 0;
114
+ }
115
+
116
+ .not-supported {
117
+ display: none;
118
+ color: #b00;
119
+ padding: 1rem;
120
+ background: #fee;
121
+ border-radius: 4px;
122
+ margin-top: 1rem;
123
+ }
124
+
125
+ esp-web-install-button[install-unsupported] + .not-supported {
126
+ display: block;
127
+ }
128
+
129
+ .insecure-context {
130
+ display: none;
131
+ }
132
+
133
+ .insecure-context .warning {
134
+ background: var(--warning-bg);
135
+ border: 1px solid var(--warning-border);
136
+ border-radius: 8px;
137
+ padding: 1rem 1.5rem;
138
+ margin-bottom: 1.5rem;
139
+ }
140
+
141
+ body.insecure .secure-only {
142
+ display: none;
143
+ }
144
+
145
+ body.insecure .insecure-context {
146
+ display: block;
147
+ }
148
+
149
+ footer {
150
+ margin-top: 3rem;
151
+ padding-top: 1.5rem;
152
+ border-top: 1px solid var(--border-color);
153
+ font-size: 0.85rem;
154
+ color: var(--text-color);
155
+ opacity: 0.7;
156
+ }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <h1>{{ title }} <span class="chip-badge">{{ chip_family }}</span></h1>
161
+
162
+ <div class="card install-section secure-only">
163
+ <h2>Install Firmware</h2>
164
+ <p>Connect your device via USB and click the button below to install.</p>
165
+
166
+ <esp-web-install-button manifest="manifest.json"></esp-web-install-button>
167
+
168
+ <div class="not-supported">
169
+ <strong>Browser not supported.</strong><br>
170
+ ESP Web Tools requires a Chromium-based browser (Chrome, Edge) with Web Serial support.
171
+ Please use one of these browsers, or follow the manual installation instructions below.
172
+ </div>
173
+ </div>
174
+
175
+ <div class="insecure-context">
176
+ <div class="warning">
177
+ <strong>Browser-based installation requires HTTPS.</strong><br>
178
+ This page is not served over a secure connection, so the install button won't work.
179
+ Use <a href="https://web.esphome.io" target="_blank">web.esphome.io</a> to install the firmware instead.
180
+ </div>
181
+
182
+ <h2>Manual Installation</h2>
183
+ <div class="card">
184
+ <p>Install using <a href="https://web.esphome.io" target="_blank">web.esphome.io</a>:</p>
185
+ <ol>
186
+ <li>Download the <a href="firmware.bin" download>firmware binary</a> below</li>
187
+ <li>Go to <a href="https://web.esphome.io" target="_blank">web.esphome.io</a></li>
188
+ <li>Click <strong>CONNECT</strong> and select your device</li>
189
+ <li>Click the three dots menu and select <strong>Install</strong></li>
190
+ <li>Choose the downloaded <code>firmware.bin</code> file</li>
191
+ <li>Wait for the installation to complete</li>
192
+ </ol>
193
+ </div>
194
+ </div>
195
+
196
+ <h2>Downloads</h2>
197
+ <p>
198
+ <a href="{{ yaml_filename }}" class="download-link" download>Download ESPHome YAML</a>
199
+ <a href="firmware.bin" class="download-link" download>Download Firmware Binary</a>
200
+ </p>
201
+
202
+ <h2>ESPHome Configuration</h2>
203
+ <p>
204
+ Want to customize this firmware? Download the <a href="{{ yaml_filename }}" download>YAML configuration</a>
205
+ and use it with <a href="https://esphome.io" target="_blank">ESPHome</a>.
206
+ </p>
207
+
208
+ <footer>
209
+ <p>
210
+ Generated on {{ build_date }} by <a href="https://github.com/esphome/ewt-gen" target="_blank">ewt-gen</a>.
211
+ Powered by <a href="https://esphome.github.io/esp-web-tools/" target="_blank">ESP Web Tools</a>
212
+ and <a href="https://esphome.io" target="_blank">ESPHome</a>
213
+ by the <a href="https://www.openhomefoundation.org/" target="_blank">Open Home Foundation</a>.
214
+ </p>
215
+ </footer>
216
+
217
+ <script>
218
+ if (!window.isSecureContext) {
219
+ document.body.classList.add('insecure');
220
+ }
221
+ </script>
222
+ </body>
223
+ </html>
ewt_gen-0.1.0/uv.lock ADDED
@@ -0,0 +1,103 @@
1
+ version = 1
2
+ revision = 1
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "click"
7
+ version = "8.3.1"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "colorama", marker = "sys_platform == 'win32'" },
11
+ ]
12
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
13
+ wheels = [
14
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
15
+ ]
16
+
17
+ [[package]]
18
+ name = "colorama"
19
+ version = "0.4.6"
20
+ source = { registry = "https://pypi.org/simple" }
21
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "ewt-gen"
28
+ version = "0.1.0"
29
+ source = { editable = "." }
30
+ dependencies = [
31
+ { name = "click" },
32
+ { name = "pyyaml" },
33
+ ]
34
+
35
+ [package.metadata]
36
+ requires-dist = [
37
+ { name = "click", specifier = ">=8.0" },
38
+ { name = "pyyaml", specifier = ">=6.0" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "pyyaml"
43
+ version = "6.0.3"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
48
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
49
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
50
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
51
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
52
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
53
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
54
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
55
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
56
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
57
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
58
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
59
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
60
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
61
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
62
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
63
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
64
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
65
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
66
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
67
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
68
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
69
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
70
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
71
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
72
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
73
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
74
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
75
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
76
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
77
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
78
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
79
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
80
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
81
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
82
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
83
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
84
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
85
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
86
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
87
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
88
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
89
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
90
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
91
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
92
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
93
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
94
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
95
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
96
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
97
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
98
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
99
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
100
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
101
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
102
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
103
+ ]