go-embed-py 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.
- go_embed_py-0.1.0/.gitignore +13 -0
- go_embed_py-0.1.0/LICENSE +21 -0
- go_embed_py-0.1.0/PKG-INFO +258 -0
- go_embed_py-0.1.0/README.md +228 -0
- go_embed_py-0.1.0/pyproject.toml +73 -0
- go_embed_py-0.1.0/src/staticembed/__init__.py +48 -0
- go_embed_py-0.1.0/src/staticembed/__main__.py +7 -0
- go_embed_py-0.1.0/src/staticembed/_cli.py +156 -0
- go_embed_py-0.1.0/src/staticembed/_core.py +378 -0
- go_embed_py-0.1.0/src/staticembed/py.typed.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thirukumaran Ganesan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: go-embed-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Go-style file embedding for Python — embed static files into generated Python modules at build time
|
|
5
|
+
Project-URL: Homepage, https://github.com/thirukguru/go-embed-py
|
|
6
|
+
Project-URL: Repository, https://github.com/thirukguru/go-embed-py
|
|
7
|
+
Project-URL: Issues, https://github.com/thirukguru/go-embed-py/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/thirukguru/go-embed-py#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/thirukguru/go-embed-py/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Thiruthanikan <thirukguru@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: assets,binary,build-tool,bundle,codegen,embed,go-embed,static
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
25
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# staticembed
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/staticembed/)
|
|
34
|
+
[](https://pypi.org/project/staticembed/)
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
|
|
37
|
+
**Go-style file embedding for Python.** Embed static files into generated Python modules at build time. At runtime, everything is in-memory — zero filesystem access.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Go: //go:embed schema.sql → var schema []byte
|
|
41
|
+
Python: # staticembed: schema.sql → SCHEMA: bytes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Why?
|
|
45
|
+
|
|
46
|
+
Python has `importlib.resources` for accessing package data, but it still reads from the filesystem at runtime. If you want **true embedding** — data baked into your code as byte literals, no `open()`, no `Path`, no filesystem dependency — you need a code generation step. That's what `staticembed` does.
|
|
47
|
+
|
|
48
|
+
**Use cases:**
|
|
49
|
+
- SQL schemas, migrations, and queries
|
|
50
|
+
- HTML/Jinja templates
|
|
51
|
+
- Default config files (JSON, YAML, TOML)
|
|
52
|
+
- Binary assets (icons, fonts, wasm)
|
|
53
|
+
- Anything you'd `//go:embed` in Go
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install staticembed
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
### Option 1: One-liner API
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
# build_embed.py — run once at build time
|
|
67
|
+
from staticembed import embed_files
|
|
68
|
+
|
|
69
|
+
embed_files({
|
|
70
|
+
"SCHEMA": "sql/schema.sql",
|
|
71
|
+
"TEMPLATES": "templates/*.html",
|
|
72
|
+
"CONFIG": "static/defaults.json",
|
|
73
|
+
}, root_dir="assets", as_string=True)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then in your application:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from _embedded import SCHEMA, TEMPLATES, CONFIG
|
|
80
|
+
|
|
81
|
+
print(CONFIG) # str — zero file I/O
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Option 2: Go-style Directives
|
|
85
|
+
|
|
86
|
+
Add `# staticembed:` comments to your source file:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# app.py
|
|
90
|
+
|
|
91
|
+
# staticembed: sql/schema.sql
|
|
92
|
+
SCHEMA: bytes
|
|
93
|
+
|
|
94
|
+
# staticembed: templates/*.html
|
|
95
|
+
TEMPLATES: dict[str, bytes]
|
|
96
|
+
|
|
97
|
+
# staticembed: static/config.json -> CONFIG string
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Generate the embed module:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
staticembed generate --source app.py --root assets/
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Option 3: CLI Only
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
staticembed generate \
|
|
110
|
+
--root ./assets \
|
|
111
|
+
--output _embedded.py \
|
|
112
|
+
--string \
|
|
113
|
+
-f SCHEMA=sql/schema.sql \
|
|
114
|
+
TEMPLATES="templates/*.html" \
|
|
115
|
+
CONFIG=static/config.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Directive Syntax
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
# staticembed: <glob> [-> VAR_NAME] [string] [compress]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Part | Required | Description |
|
|
125
|
+
|------|----------|-------------|
|
|
126
|
+
| `<glob>` | Yes | File path or glob pattern |
|
|
127
|
+
| `-> VAR_NAME` | No | Explicit variable name (auto-derived from filename otherwise) |
|
|
128
|
+
| `string` | No | Decode as UTF-8 `str` instead of `bytes` |
|
|
129
|
+
| `compress` | No | zlib compress (smaller module, tiny runtime cost) |
|
|
130
|
+
|
|
131
|
+
**Annotation style** — variable name + type hint on the next line:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# staticembed: sql/schema.sql
|
|
135
|
+
SCHEMA: bytes
|
|
136
|
+
|
|
137
|
+
# staticembed: templates/*.html
|
|
138
|
+
TEMPLATES: dict[str, bytes]
|
|
139
|
+
|
|
140
|
+
# staticembed: config.json
|
|
141
|
+
CONFIG: str # ← 'str' annotation enables string mode automatically
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Features
|
|
145
|
+
|
|
146
|
+
- **Glob support**: `*.html`, `sql/**/*.sql`, `static/*`
|
|
147
|
+
- **Single file → variable**, **glob → `dict[str, bytes|str]`**
|
|
148
|
+
- **Compression**: `--compress` flag → zlib level 9
|
|
149
|
+
- **String decode**: `--string` flag → UTF-8 `str`
|
|
150
|
+
- **Binary safe**: images, fonts, wasm, protobuf — anything
|
|
151
|
+
- **Manifest**: SHA-256 hashes + sizes for cache invalidation
|
|
152
|
+
- **AST validation**: generated code is syntax-checked before writing
|
|
153
|
+
- **Inspect mode**: preview what would be embedded without generating
|
|
154
|
+
- **Zero dependencies**: stdlib only, Python 3.9+
|
|
155
|
+
|
|
156
|
+
## CLI Reference
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Generate from source directives
|
|
160
|
+
staticembed generate --source app.py --root ./data
|
|
161
|
+
|
|
162
|
+
# Generate from CLI args
|
|
163
|
+
staticembed generate -f SCHEMA=sql/*.sql CONFIG=config.json --root .
|
|
164
|
+
|
|
165
|
+
# With compression
|
|
166
|
+
staticembed generate -f DATA=big_file.json --compress --root .
|
|
167
|
+
|
|
168
|
+
# As strings instead of bytes
|
|
169
|
+
staticembed generate -f PAGES="*.html" --string --root .
|
|
170
|
+
|
|
171
|
+
# Inspect (dry run)
|
|
172
|
+
staticembed inspect --source app.py --root ./data
|
|
173
|
+
staticembed inspect -f SCHEMA=sql/*.sql --root .
|
|
174
|
+
|
|
175
|
+
# Version
|
|
176
|
+
staticembed --version
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Integration
|
|
180
|
+
|
|
181
|
+
### Makefile
|
|
182
|
+
|
|
183
|
+
```makefile
|
|
184
|
+
embed:
|
|
185
|
+
staticembed generate --source app.py --root assets/
|
|
186
|
+
|
|
187
|
+
build: embed
|
|
188
|
+
pyinstaller --onefile app.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### pyproject.toml (hatch build hook)
|
|
192
|
+
|
|
193
|
+
```toml
|
|
194
|
+
[tool.hatch.build.hooks.custom]
|
|
195
|
+
path = "build_hooks.py"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
# build_hooks.py
|
|
200
|
+
from staticembed import embed_files
|
|
201
|
+
|
|
202
|
+
embed_files({"SCHEMA": "sql/*.sql"}, root_dir="src/assets")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Pre-commit / CI
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
# .github/workflows/embed.yml
|
|
209
|
+
- run: pip install staticembed
|
|
210
|
+
- run: staticembed generate --source src/app.py --root assets/
|
|
211
|
+
- run: git diff --exit-code _embedded.py # fail if stale
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Go ↔ Python Comparison
|
|
215
|
+
|
|
216
|
+
| Go | staticembed |
|
|
217
|
+
|----|-------------|
|
|
218
|
+
| `//go:embed schema.sql` | `# staticembed: schema.sql` |
|
|
219
|
+
| `var schema []byte` | `SCHEMA: bytes` |
|
|
220
|
+
| `//go:embed static/*` | `# staticembed: static/*` |
|
|
221
|
+
| `var static embed.FS` | `STATIC: dict[str, bytes]` |
|
|
222
|
+
| Compile-time | Build-time (pre-step) |
|
|
223
|
+
| In binary | In generated `.py` module |
|
|
224
|
+
|
|
225
|
+
## API Reference
|
|
226
|
+
|
|
227
|
+
### `embed_files(file_map, root_dir=".", output="_embedded.py", compress=False, as_string=False)`
|
|
228
|
+
|
|
229
|
+
One-liner API. Maps variable names to file paths/globs. Returns `Path` to the generated module.
|
|
230
|
+
|
|
231
|
+
### `embed_from_source(source_path, root_dir=None, output="_embedded.py", compress=False)`
|
|
232
|
+
|
|
233
|
+
Parses `# staticembed:` directives from a Python source file. Returns `Path`.
|
|
234
|
+
|
|
235
|
+
### `generate(spec: EmbedSpec) -> str`
|
|
236
|
+
|
|
237
|
+
Low-level: returns the generated module source as a string.
|
|
238
|
+
|
|
239
|
+
### `parse_directives(source_path: Path) -> list[EmbedDirective]`
|
|
240
|
+
|
|
241
|
+
Extract directives from a source file without generating.
|
|
242
|
+
|
|
243
|
+
### `resolve_files(root: Path, pattern: str) -> list[tuple[str, bytes]]`
|
|
244
|
+
|
|
245
|
+
Resolve a glob pattern and read matching files.
|
|
246
|
+
|
|
247
|
+
## Contributing
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
git clone https://github.com/thirukguru/staticembed
|
|
251
|
+
cd staticembed
|
|
252
|
+
pip install -e ".[dev]"
|
|
253
|
+
pytest
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# staticembed
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/staticembed/)
|
|
4
|
+
[](https://pypi.org/project/staticembed/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Go-style file embedding for Python.** Embed static files into generated Python modules at build time. At runtime, everything is in-memory — zero filesystem access.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Go: //go:embed schema.sql → var schema []byte
|
|
11
|
+
Python: # staticembed: schema.sql → SCHEMA: bytes
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Why?
|
|
15
|
+
|
|
16
|
+
Python has `importlib.resources` for accessing package data, but it still reads from the filesystem at runtime. If you want **true embedding** — data baked into your code as byte literals, no `open()`, no `Path`, no filesystem dependency — you need a code generation step. That's what `staticembed` does.
|
|
17
|
+
|
|
18
|
+
**Use cases:**
|
|
19
|
+
- SQL schemas, migrations, and queries
|
|
20
|
+
- HTML/Jinja templates
|
|
21
|
+
- Default config files (JSON, YAML, TOML)
|
|
22
|
+
- Binary assets (icons, fonts, wasm)
|
|
23
|
+
- Anything you'd `//go:embed` in Go
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install staticembed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
### Option 1: One-liner API
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# build_embed.py — run once at build time
|
|
37
|
+
from staticembed import embed_files
|
|
38
|
+
|
|
39
|
+
embed_files({
|
|
40
|
+
"SCHEMA": "sql/schema.sql",
|
|
41
|
+
"TEMPLATES": "templates/*.html",
|
|
42
|
+
"CONFIG": "static/defaults.json",
|
|
43
|
+
}, root_dir="assets", as_string=True)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then in your application:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from _embedded import SCHEMA, TEMPLATES, CONFIG
|
|
50
|
+
|
|
51
|
+
print(CONFIG) # str — zero file I/O
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Option 2: Go-style Directives
|
|
55
|
+
|
|
56
|
+
Add `# staticembed:` comments to your source file:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# app.py
|
|
60
|
+
|
|
61
|
+
# staticembed: sql/schema.sql
|
|
62
|
+
SCHEMA: bytes
|
|
63
|
+
|
|
64
|
+
# staticembed: templates/*.html
|
|
65
|
+
TEMPLATES: dict[str, bytes]
|
|
66
|
+
|
|
67
|
+
# staticembed: static/config.json -> CONFIG string
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Generate the embed module:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
staticembed generate --source app.py --root assets/
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Option 3: CLI Only
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
staticembed generate \
|
|
80
|
+
--root ./assets \
|
|
81
|
+
--output _embedded.py \
|
|
82
|
+
--string \
|
|
83
|
+
-f SCHEMA=sql/schema.sql \
|
|
84
|
+
TEMPLATES="templates/*.html" \
|
|
85
|
+
CONFIG=static/config.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Directive Syntax
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
# staticembed: <glob> [-> VAR_NAME] [string] [compress]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Part | Required | Description |
|
|
95
|
+
|------|----------|-------------|
|
|
96
|
+
| `<glob>` | Yes | File path or glob pattern |
|
|
97
|
+
| `-> VAR_NAME` | No | Explicit variable name (auto-derived from filename otherwise) |
|
|
98
|
+
| `string` | No | Decode as UTF-8 `str` instead of `bytes` |
|
|
99
|
+
| `compress` | No | zlib compress (smaller module, tiny runtime cost) |
|
|
100
|
+
|
|
101
|
+
**Annotation style** — variable name + type hint on the next line:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# staticembed: sql/schema.sql
|
|
105
|
+
SCHEMA: bytes
|
|
106
|
+
|
|
107
|
+
# staticembed: templates/*.html
|
|
108
|
+
TEMPLATES: dict[str, bytes]
|
|
109
|
+
|
|
110
|
+
# staticembed: config.json
|
|
111
|
+
CONFIG: str # ← 'str' annotation enables string mode automatically
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Features
|
|
115
|
+
|
|
116
|
+
- **Glob support**: `*.html`, `sql/**/*.sql`, `static/*`
|
|
117
|
+
- **Single file → variable**, **glob → `dict[str, bytes|str]`**
|
|
118
|
+
- **Compression**: `--compress` flag → zlib level 9
|
|
119
|
+
- **String decode**: `--string` flag → UTF-8 `str`
|
|
120
|
+
- **Binary safe**: images, fonts, wasm, protobuf — anything
|
|
121
|
+
- **Manifest**: SHA-256 hashes + sizes for cache invalidation
|
|
122
|
+
- **AST validation**: generated code is syntax-checked before writing
|
|
123
|
+
- **Inspect mode**: preview what would be embedded without generating
|
|
124
|
+
- **Zero dependencies**: stdlib only, Python 3.9+
|
|
125
|
+
|
|
126
|
+
## CLI Reference
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Generate from source directives
|
|
130
|
+
staticembed generate --source app.py --root ./data
|
|
131
|
+
|
|
132
|
+
# Generate from CLI args
|
|
133
|
+
staticembed generate -f SCHEMA=sql/*.sql CONFIG=config.json --root .
|
|
134
|
+
|
|
135
|
+
# With compression
|
|
136
|
+
staticembed generate -f DATA=big_file.json --compress --root .
|
|
137
|
+
|
|
138
|
+
# As strings instead of bytes
|
|
139
|
+
staticembed generate -f PAGES="*.html" --string --root .
|
|
140
|
+
|
|
141
|
+
# Inspect (dry run)
|
|
142
|
+
staticembed inspect --source app.py --root ./data
|
|
143
|
+
staticembed inspect -f SCHEMA=sql/*.sql --root .
|
|
144
|
+
|
|
145
|
+
# Version
|
|
146
|
+
staticembed --version
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Integration
|
|
150
|
+
|
|
151
|
+
### Makefile
|
|
152
|
+
|
|
153
|
+
```makefile
|
|
154
|
+
embed:
|
|
155
|
+
staticembed generate --source app.py --root assets/
|
|
156
|
+
|
|
157
|
+
build: embed
|
|
158
|
+
pyinstaller --onefile app.py
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### pyproject.toml (hatch build hook)
|
|
162
|
+
|
|
163
|
+
```toml
|
|
164
|
+
[tool.hatch.build.hooks.custom]
|
|
165
|
+
path = "build_hooks.py"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# build_hooks.py
|
|
170
|
+
from staticembed import embed_files
|
|
171
|
+
|
|
172
|
+
embed_files({"SCHEMA": "sql/*.sql"}, root_dir="src/assets")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Pre-commit / CI
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
# .github/workflows/embed.yml
|
|
179
|
+
- run: pip install staticembed
|
|
180
|
+
- run: staticembed generate --source src/app.py --root assets/
|
|
181
|
+
- run: git diff --exit-code _embedded.py # fail if stale
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Go ↔ Python Comparison
|
|
185
|
+
|
|
186
|
+
| Go | staticembed |
|
|
187
|
+
|----|-------------|
|
|
188
|
+
| `//go:embed schema.sql` | `# staticembed: schema.sql` |
|
|
189
|
+
| `var schema []byte` | `SCHEMA: bytes` |
|
|
190
|
+
| `//go:embed static/*` | `# staticembed: static/*` |
|
|
191
|
+
| `var static embed.FS` | `STATIC: dict[str, bytes]` |
|
|
192
|
+
| Compile-time | Build-time (pre-step) |
|
|
193
|
+
| In binary | In generated `.py` module |
|
|
194
|
+
|
|
195
|
+
## API Reference
|
|
196
|
+
|
|
197
|
+
### `embed_files(file_map, root_dir=".", output="_embedded.py", compress=False, as_string=False)`
|
|
198
|
+
|
|
199
|
+
One-liner API. Maps variable names to file paths/globs. Returns `Path` to the generated module.
|
|
200
|
+
|
|
201
|
+
### `embed_from_source(source_path, root_dir=None, output="_embedded.py", compress=False)`
|
|
202
|
+
|
|
203
|
+
Parses `# staticembed:` directives from a Python source file. Returns `Path`.
|
|
204
|
+
|
|
205
|
+
### `generate(spec: EmbedSpec) -> str`
|
|
206
|
+
|
|
207
|
+
Low-level: returns the generated module source as a string.
|
|
208
|
+
|
|
209
|
+
### `parse_directives(source_path: Path) -> list[EmbedDirective]`
|
|
210
|
+
|
|
211
|
+
Extract directives from a source file without generating.
|
|
212
|
+
|
|
213
|
+
### `resolve_files(root: Path, pattern: str) -> list[tuple[str, bytes]]`
|
|
214
|
+
|
|
215
|
+
Resolve a glob pattern and read matching files.
|
|
216
|
+
|
|
217
|
+
## Contributing
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
git clone https://github.com/thirukguru/staticembed
|
|
221
|
+
cd staticembed
|
|
222
|
+
pip install -e ".[dev]"
|
|
223
|
+
pytest
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "go-embed-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Go-style file embedding for Python — embed static files into generated Python modules at build time"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Thiruthanikan", email = "thirukguru@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"embed",
|
|
17
|
+
"static",
|
|
18
|
+
"assets",
|
|
19
|
+
"codegen",
|
|
20
|
+
"build-tool",
|
|
21
|
+
"go-embed",
|
|
22
|
+
"bundle",
|
|
23
|
+
"binary",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.9",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Programming Language :: Python :: 3.13",
|
|
36
|
+
"Topic :: Software Development :: Build Tools",
|
|
37
|
+
"Topic :: Software Development :: Code Generators",
|
|
38
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/thirukguru/go-embed-py"
|
|
44
|
+
Repository = "https://github.com/thirukguru/go-embed-py"
|
|
45
|
+
Issues = "https://github.com/thirukguru/go-embed-py/issues"
|
|
46
|
+
Documentation = "https://github.com/thirukguru/go-embed-py#readme"
|
|
47
|
+
Changelog = "https://github.com/thirukguru/go-embed-py/blob/main/CHANGELOG.md"
|
|
48
|
+
|
|
49
|
+
[project.scripts]
|
|
50
|
+
staticembed = "staticembed._cli:main"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src/staticembed/**"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/staticembed"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
pythonpath = ["src"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.9"
|
|
64
|
+
strict = true
|
|
65
|
+
warn_return_any = true
|
|
66
|
+
warn_unused_configs = true
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
target-version = "py39"
|
|
70
|
+
line-length = 100
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
staticembed — Go-style file embedding for Python.
|
|
3
|
+
|
|
4
|
+
Embed static files into generated Python modules at build time.
|
|
5
|
+
At runtime, everything is in-memory — zero filesystem access.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from staticembed import embed_files
|
|
10
|
+
|
|
11
|
+
embed_files({
|
|
12
|
+
"SCHEMA": "sql/schema.sql",
|
|
13
|
+
"TEMPLATES": "templates/*.html",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
Go equivalent mapping::
|
|
17
|
+
|
|
18
|
+
//go:embed schema.sql → # staticembed: schema.sql
|
|
19
|
+
//go:embed static/* → # staticembed: static/*
|
|
20
|
+
//go:embed templates/*.html → # staticembed: templates/*.html
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from staticembed._core import (
|
|
26
|
+
EmbedDirective,
|
|
27
|
+
EmbedEntry,
|
|
28
|
+
EmbedSpec,
|
|
29
|
+
embed_files,
|
|
30
|
+
embed_from_source,
|
|
31
|
+
generate,
|
|
32
|
+
parse_directives,
|
|
33
|
+
resolve_files,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"EmbedDirective",
|
|
40
|
+
"EmbedEntry",
|
|
41
|
+
"EmbedSpec",
|
|
42
|
+
"embed_files",
|
|
43
|
+
"embed_from_source",
|
|
44
|
+
"generate",
|
|
45
|
+
"parse_directives",
|
|
46
|
+
"resolve_files",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""CLI for staticembed.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
# From directives in source file
|
|
6
|
+
staticembed generate --source app.py --root assets/
|
|
7
|
+
|
|
8
|
+
# From CLI arguments
|
|
9
|
+
staticembed generate -f SCHEMA=sql/schema.sql TEMPLATES="templates/*.html"
|
|
10
|
+
|
|
11
|
+
# Inspect without generating
|
|
12
|
+
staticembed inspect --source app.py --root assets/
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from staticembed._core import (
|
|
22
|
+
embed_files,
|
|
23
|
+
embed_from_source,
|
|
24
|
+
parse_directives,
|
|
25
|
+
resolve_files,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
"""Entry point for the ``staticembed`` CLI."""
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="staticembed",
|
|
33
|
+
description="Go-style file embedding for Python",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--version",
|
|
37
|
+
action="version",
|
|
38
|
+
version=f"%(prog)s {_get_version()}",
|
|
39
|
+
)
|
|
40
|
+
sub = parser.add_subparsers(dest="command")
|
|
41
|
+
|
|
42
|
+
# --- generate ---
|
|
43
|
+
gen = sub.add_parser("generate", help="Generate embedded Python module")
|
|
44
|
+
gen.add_argument(
|
|
45
|
+
"--source", "-s",
|
|
46
|
+
help="Source file containing # staticembed: directives",
|
|
47
|
+
)
|
|
48
|
+
gen.add_argument("--root", "-r", default=".", help="Root directory for file resolution")
|
|
49
|
+
gen.add_argument("--output", "-o", default="_embedded.py", help="Output module path")
|
|
50
|
+
gen.add_argument("--compress", "-z", action="store_true", help="Compress with zlib")
|
|
51
|
+
gen.add_argument("--string", action="store_true", help="Decode as UTF-8 strings")
|
|
52
|
+
gen.add_argument(
|
|
53
|
+
"--files", "-f",
|
|
54
|
+
nargs="+",
|
|
55
|
+
metavar="VAR=GLOB",
|
|
56
|
+
help="Embed files: VAR_NAME=path/glob (e.g. SCHEMA=sql/*.sql)",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# --- inspect ---
|
|
60
|
+
insp = sub.add_parser("inspect", help="Show what would be embedded (dry run)")
|
|
61
|
+
insp.add_argument("--source", "-s", help="Source file with directives")
|
|
62
|
+
insp.add_argument("--root", "-r", default=".", help="Root directory")
|
|
63
|
+
insp.add_argument("--files", "-f", nargs="+", metavar="VAR=GLOB")
|
|
64
|
+
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
|
|
67
|
+
if args.command == "generate":
|
|
68
|
+
return _cmd_generate(args, parser)
|
|
69
|
+
elif args.command == "inspect":
|
|
70
|
+
return _cmd_inspect(args, parser)
|
|
71
|
+
else:
|
|
72
|
+
parser.print_help()
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cmd_generate(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
77
|
+
if args.source:
|
|
78
|
+
out = embed_from_source(args.source, args.root, args.output, args.compress)
|
|
79
|
+
print(f"Generated {out} from directives in {args.source}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if args.files:
|
|
83
|
+
file_map = _parse_file_args(args.files, parser)
|
|
84
|
+
out = embed_files(file_map, args.root, args.output, args.compress, args.string)
|
|
85
|
+
print(f"Generated {out} with {len(file_map)} embed(s)")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
parser.error("Provide --source or --files")
|
|
89
|
+
return 1 # unreachable
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_inspect(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
93
|
+
root = Path(args.root).resolve()
|
|
94
|
+
total_files = 0
|
|
95
|
+
total_bytes = 0
|
|
96
|
+
|
|
97
|
+
if args.source:
|
|
98
|
+
directives = parse_directives(Path(args.source))
|
|
99
|
+
if not directives:
|
|
100
|
+
print(f"No staticembed directives found in {args.source}")
|
|
101
|
+
return 1
|
|
102
|
+
print(f"Directives in {args.source}:\n")
|
|
103
|
+
for d in directives:
|
|
104
|
+
files = resolve_files(root, d.pattern)
|
|
105
|
+
flags = []
|
|
106
|
+
if d.as_string:
|
|
107
|
+
flags.append("string")
|
|
108
|
+
if d.compress:
|
|
109
|
+
flags.append("compress")
|
|
110
|
+
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
|
111
|
+
print(f" {d.var_name} <- {d.pattern}{flag_str} ({len(files)} file(s))")
|
|
112
|
+
for rel, data in files:
|
|
113
|
+
print(f" {rel} ({len(data):,} bytes)")
|
|
114
|
+
total_files += 1
|
|
115
|
+
total_bytes += len(data)
|
|
116
|
+
|
|
117
|
+
elif args.files:
|
|
118
|
+
file_map = _parse_file_args(args.files, parser)
|
|
119
|
+
for var, glob in file_map.items():
|
|
120
|
+
files = resolve_files(root, glob)
|
|
121
|
+
print(f" {var} <- {glob} ({len(files)} file(s))")
|
|
122
|
+
for rel, data in files:
|
|
123
|
+
print(f" {rel} ({len(data):,} bytes)")
|
|
124
|
+
total_files += 1
|
|
125
|
+
total_bytes += len(data)
|
|
126
|
+
else:
|
|
127
|
+
parser.error("Provide --source or --files")
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
print(f"\nTotal: {total_files} file(s), {total_bytes:,} bytes")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_file_args(
|
|
135
|
+
items: list[str], parser: argparse.ArgumentParser
|
|
136
|
+
) -> dict[str, str]:
|
|
137
|
+
file_map: dict[str, str] = {}
|
|
138
|
+
for item in items:
|
|
139
|
+
var, sep, glob = item.partition("=")
|
|
140
|
+
if not sep or not glob:
|
|
141
|
+
parser.error(f"Invalid format: {item!r} (expected VAR=GLOB)")
|
|
142
|
+
file_map[var.strip()] = glob.strip()
|
|
143
|
+
return file_map
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _get_version() -> str:
|
|
147
|
+
try:
|
|
148
|
+
from staticembed import __version__
|
|
149
|
+
|
|
150
|
+
return __version__
|
|
151
|
+
except ImportError:
|
|
152
|
+
return "unknown"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
sys.exit(main())
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Core embedding engine for staticembed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import zlib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Data structures
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EmbedEntry:
|
|
22
|
+
"""A single embedded file with metadata."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
path: str
|
|
26
|
+
data: bytes
|
|
27
|
+
size: int = 0
|
|
28
|
+
sha256: str = ""
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
self.size = len(self.data)
|
|
32
|
+
self.sha256 = hashlib.sha256(self.data).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class EmbedDirective:
|
|
37
|
+
"""Parsed from source file ``# staticembed:`` comments."""
|
|
38
|
+
|
|
39
|
+
var_name: str
|
|
40
|
+
pattern: str
|
|
41
|
+
as_string: bool = False
|
|
42
|
+
compress: bool = False
|
|
43
|
+
line_number: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EmbedSpec:
|
|
48
|
+
"""Complete specification for a code generation run."""
|
|
49
|
+
|
|
50
|
+
root_dir: Path
|
|
51
|
+
output_path: Path
|
|
52
|
+
directives: list[EmbedDirective] = field(default_factory=list)
|
|
53
|
+
file_map: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
compress: bool = False
|
|
55
|
+
as_string: bool = False
|
|
56
|
+
banner: str = "# AUTO-GENERATED by staticembed — do not edit"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Directive parser
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
_DIRECTIVE_RE = re.compile(
|
|
64
|
+
r"^\s*#\s*staticembed:\s*"
|
|
65
|
+
r"(?P<pattern>[^\s#]+)"
|
|
66
|
+
r"(?:\s*->\s*(?P<var>[A-Za-z_][A-Za-z0-9_]*))?"
|
|
67
|
+
r"(?P<flags>(?:\s+(?:string|compress))*)"
|
|
68
|
+
r"\s*$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_directives(source_path: Path) -> list[EmbedDirective]:
|
|
73
|
+
"""Extract ``# staticembed:`` directives from a Python source file.
|
|
74
|
+
|
|
75
|
+
Supports two styles:
|
|
76
|
+
|
|
77
|
+
**Style 1 — inline (Go-like)**::
|
|
78
|
+
|
|
79
|
+
# staticembed: sql/schema.sql
|
|
80
|
+
# staticembed: templates/*.html -> TEMPLATES
|
|
81
|
+
# staticembed: config.json string compress
|
|
82
|
+
|
|
83
|
+
**Style 2 — comment + annotation on next line**::
|
|
84
|
+
|
|
85
|
+
# staticembed: sql/schema.sql
|
|
86
|
+
SCHEMA: bytes
|
|
87
|
+
|
|
88
|
+
# staticembed: templates/*.html
|
|
89
|
+
TEMPLATES: dict[str, bytes]
|
|
90
|
+
"""
|
|
91
|
+
directives: list[EmbedDirective] = []
|
|
92
|
+
lines = source_path.read_text(encoding="utf-8").splitlines()
|
|
93
|
+
|
|
94
|
+
i = 0
|
|
95
|
+
while i < len(lines):
|
|
96
|
+
m = _DIRECTIVE_RE.match(lines[i])
|
|
97
|
+
if m:
|
|
98
|
+
pattern = m.group("pattern")
|
|
99
|
+
var_name = m.group("var")
|
|
100
|
+
flags = m.group("flags") or ""
|
|
101
|
+
as_string = "string" in flags
|
|
102
|
+
compress = "compress" in flags
|
|
103
|
+
|
|
104
|
+
# Check next line for annotation-style var name
|
|
105
|
+
if not var_name and i + 1 < len(lines):
|
|
106
|
+
next_line = lines[i + 1].strip()
|
|
107
|
+
ann_match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:", next_line)
|
|
108
|
+
if ann_match:
|
|
109
|
+
var_name = ann_match.group(1)
|
|
110
|
+
if "str" in next_line and "bytes" not in next_line:
|
|
111
|
+
as_string = True
|
|
112
|
+
i += 1
|
|
113
|
+
|
|
114
|
+
if not var_name:
|
|
115
|
+
var_name = _path_to_var(pattern)
|
|
116
|
+
|
|
117
|
+
directives.append(
|
|
118
|
+
EmbedDirective(
|
|
119
|
+
var_name=var_name,
|
|
120
|
+
pattern=pattern,
|
|
121
|
+
as_string=as_string,
|
|
122
|
+
compress=compress,
|
|
123
|
+
line_number=i + 1,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
i += 1
|
|
127
|
+
|
|
128
|
+
return directives
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _path_to_var(pattern: str) -> str:
|
|
132
|
+
"""Convert a file path/glob to a valid Python variable name."""
|
|
133
|
+
name = Path(pattern).stem
|
|
134
|
+
name = re.sub(r"[^A-Za-z0-9]", "_", name)
|
|
135
|
+
name = name.strip("_").upper()
|
|
136
|
+
if name and name[0].isdigit():
|
|
137
|
+
name = "_" + name
|
|
138
|
+
return name or "_EMBED"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# File resolver
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def resolve_files(root: Path, pattern: str) -> list[tuple[str, bytes]]:
|
|
146
|
+
"""Resolve a glob pattern relative to *root*.
|
|
147
|
+
|
|
148
|
+
Returns a sorted list of ``(relative_path, content)`` pairs.
|
|
149
|
+
"""
|
|
150
|
+
matches = sorted(root.glob(pattern))
|
|
151
|
+
results: list[tuple[str, bytes]] = []
|
|
152
|
+
for p in matches:
|
|
153
|
+
if p.is_file():
|
|
154
|
+
rel = p.relative_to(root)
|
|
155
|
+
results.append((str(rel), p.read_bytes()))
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Code generator
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def generate(spec: EmbedSpec) -> str:
|
|
164
|
+
"""Generate a Python module containing all embedded data.
|
|
165
|
+
|
|
166
|
+
Returns the module source code as a string.
|
|
167
|
+
"""
|
|
168
|
+
lines: list[str] = [
|
|
169
|
+
spec.banner,
|
|
170
|
+
'"""Embedded assets generated by staticembed."""',
|
|
171
|
+
"",
|
|
172
|
+
"from __future__ import annotations",
|
|
173
|
+
"",
|
|
174
|
+
"import base64 as _b64",
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
needs_zlib = spec.compress or any(d.compress for d in spec.directives)
|
|
178
|
+
if needs_zlib:
|
|
179
|
+
lines.append("import zlib as _zlib")
|
|
180
|
+
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
entries: list[EmbedEntry] = []
|
|
184
|
+
|
|
185
|
+
# Process directives (from source file parsing)
|
|
186
|
+
for directive in spec.directives:
|
|
187
|
+
_generate_for_pattern(
|
|
188
|
+
lines=lines,
|
|
189
|
+
entries=entries,
|
|
190
|
+
root=spec.root_dir,
|
|
191
|
+
var_name=directive.var_name,
|
|
192
|
+
pattern=directive.pattern,
|
|
193
|
+
compress=directive.compress or spec.compress,
|
|
194
|
+
as_string=directive.as_string or spec.as_string,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Process file_map (programmatic API)
|
|
198
|
+
for var_name, pattern in spec.file_map.items():
|
|
199
|
+
_generate_for_pattern(
|
|
200
|
+
lines=lines,
|
|
201
|
+
entries=entries,
|
|
202
|
+
root=spec.root_dir,
|
|
203
|
+
var_name=var_name,
|
|
204
|
+
pattern=pattern,
|
|
205
|
+
compress=spec.compress,
|
|
206
|
+
as_string=spec.as_string,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Manifest
|
|
210
|
+
manifest = {
|
|
211
|
+
entry.path: {"size": entry.size, "sha256": entry.sha256}
|
|
212
|
+
for entry in entries
|
|
213
|
+
}
|
|
214
|
+
lines.append("# Embed manifest (for cache invalidation / debugging)")
|
|
215
|
+
lines.append(f"_MANIFEST: dict = {json.dumps(manifest, indent=2)}")
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
source = "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
# Validate syntax
|
|
221
|
+
try:
|
|
222
|
+
ast.parse(source)
|
|
223
|
+
except SyntaxError as e:
|
|
224
|
+
raise RuntimeError(f"staticembed: generated invalid Python: {e}") from e
|
|
225
|
+
|
|
226
|
+
return source
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _generate_for_pattern(
|
|
230
|
+
*,
|
|
231
|
+
lines: list[str],
|
|
232
|
+
entries: list[EmbedEntry],
|
|
233
|
+
root: Path,
|
|
234
|
+
var_name: str,
|
|
235
|
+
pattern: str,
|
|
236
|
+
compress: bool,
|
|
237
|
+
as_string: bool,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Generate code for a single variable/pattern pair."""
|
|
240
|
+
files = resolve_files(root, pattern)
|
|
241
|
+
|
|
242
|
+
if not files:
|
|
243
|
+
lines.append(f"# WARNING: no files matched pattern '{pattern}'")
|
|
244
|
+
lines.append("")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
is_glob = "*" in pattern or "?" in pattern
|
|
248
|
+
type_name = "str" if as_string else "bytes"
|
|
249
|
+
|
|
250
|
+
if is_glob or len(files) > 1:
|
|
251
|
+
lines.append(f"# Embedded from: {pattern}")
|
|
252
|
+
lines.append(f"{var_name}: dict[str, {type_name}] = {{")
|
|
253
|
+
for rel_path, data in files:
|
|
254
|
+
entries.append(EmbedEntry(name=rel_path, path=rel_path, data=data))
|
|
255
|
+
encoded = _encode_data(data, compress)
|
|
256
|
+
accessor = _decode_expr(encoded, compress, as_string)
|
|
257
|
+
lines.append(f" {rel_path!r}: {accessor},")
|
|
258
|
+
lines.append("}")
|
|
259
|
+
else:
|
|
260
|
+
rel_path, data = files[0]
|
|
261
|
+
entries.append(EmbedEntry(name=var_name, path=rel_path, data=data))
|
|
262
|
+
encoded = _encode_data(data, compress)
|
|
263
|
+
accessor = _decode_expr(encoded, compress, as_string)
|
|
264
|
+
lines.append(f"# Embedded from: {rel_path}")
|
|
265
|
+
lines.append(f"{var_name}: {type_name} = {accessor}")
|
|
266
|
+
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _encode_data(data: bytes, compress: bool) -> str:
|
|
271
|
+
if compress:
|
|
272
|
+
data = zlib.compress(data, level=9)
|
|
273
|
+
return base64.b64encode(data).decode("ascii")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _decode_expr(encoded: str, compress: bool, as_string: bool) -> str:
|
|
277
|
+
if len(encoded) > 76:
|
|
278
|
+
chunks = [encoded[i : i + 76] for i in range(0, len(encoded), 76)]
|
|
279
|
+
b64_literal = (
|
|
280
|
+
"(\n " + "\n ".join(f'"{c}"' for c in chunks) + "\n )"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
b64_literal = f'"{encoded}"'
|
|
284
|
+
|
|
285
|
+
expr = f"_b64.b64decode({b64_literal})"
|
|
286
|
+
|
|
287
|
+
if compress:
|
|
288
|
+
expr = f"_zlib.decompress({expr})"
|
|
289
|
+
|
|
290
|
+
if as_string:
|
|
291
|
+
expr = f"({expr}).decode('utf-8')"
|
|
292
|
+
|
|
293
|
+
return expr
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Convenience API
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def embed_files(
|
|
301
|
+
file_map: dict[str, str],
|
|
302
|
+
root_dir: str | Path = ".",
|
|
303
|
+
output: str | Path = "_embedded.py",
|
|
304
|
+
compress: bool = False,
|
|
305
|
+
as_string: bool = False,
|
|
306
|
+
) -> Path:
|
|
307
|
+
"""Embed files into a generated Python module.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
file_map: Mapping of ``{"VAR_NAME": "path/or/glob", ...}``.
|
|
311
|
+
root_dir: Base directory for resolving file paths.
|
|
312
|
+
output: Path for the generated module.
|
|
313
|
+
compress: Compress data with zlib (smaller output, tiny runtime cost).
|
|
314
|
+
as_string: Decode embedded data as UTF-8 ``str`` instead of ``bytes``.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Path to the generated module file.
|
|
318
|
+
|
|
319
|
+
Example::
|
|
320
|
+
|
|
321
|
+
from staticembed import embed_files
|
|
322
|
+
|
|
323
|
+
embed_files({
|
|
324
|
+
"SCHEMA": "sql/schema.sql",
|
|
325
|
+
"TEMPLATES": "templates/*.html",
|
|
326
|
+
"CONFIG": "static/defaults.json",
|
|
327
|
+
}, as_string=True)
|
|
328
|
+
"""
|
|
329
|
+
root = Path(root_dir).resolve()
|
|
330
|
+
out = Path(output)
|
|
331
|
+
|
|
332
|
+
spec = EmbedSpec(
|
|
333
|
+
root_dir=root,
|
|
334
|
+
output_path=out,
|
|
335
|
+
file_map=file_map,
|
|
336
|
+
compress=compress,
|
|
337
|
+
as_string=as_string,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
source = generate(spec)
|
|
341
|
+
out.write_text(source, encoding="utf-8")
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def embed_from_source(
|
|
346
|
+
source_path: str | Path,
|
|
347
|
+
root_dir: Optional[str | Path] = None,
|
|
348
|
+
output: str | Path = "_embedded.py",
|
|
349
|
+
compress: bool = False,
|
|
350
|
+
) -> Path:
|
|
351
|
+
"""Parse ``# staticembed:`` directives from a source file and generate the embed module.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
source_path: Python file containing ``# staticembed:`` directives.
|
|
355
|
+
root_dir: Base directory (defaults to source file's parent).
|
|
356
|
+
output: Path for the generated module.
|
|
357
|
+
compress: Compress all embedded data.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Path to the generated module file.
|
|
361
|
+
"""
|
|
362
|
+
src = Path(source_path).resolve()
|
|
363
|
+
root = Path(root_dir).resolve() if root_dir else src.parent
|
|
364
|
+
|
|
365
|
+
directives = parse_directives(src)
|
|
366
|
+
if not directives:
|
|
367
|
+
raise ValueError(f"No staticembed directives found in {src}")
|
|
368
|
+
|
|
369
|
+
spec = EmbedSpec(
|
|
370
|
+
root_dir=root,
|
|
371
|
+
output_path=Path(output),
|
|
372
|
+
directives=directives,
|
|
373
|
+
compress=compress,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
source = generate(spec)
|
|
377
|
+
Path(output).write_text(source, encoding="utf-8")
|
|
378
|
+
return Path(output)
|
|
File without changes
|