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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ _embedded.py
12
+ *.whl
13
+ .venv/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/staticembed)](https://pypi.org/project/staticembed/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/staticembed)](https://pypi.org/project/staticembed/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/staticembed)](https://pypi.org/project/staticembed/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/staticembed)](https://pypi.org/project/staticembed/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,7 @@
1
+ """Allow running as: python -m staticembed"""
2
+
3
+ import sys
4
+
5
+ from staticembed._cli import main
6
+
7
+ sys.exit(main())
@@ -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