utfbundle 0.1.0__py3-none-any.whl
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,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utfbundle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pack a directory of text files into one self-extracting Python script, and unpack it back.
|
|
5
|
+
Project-URL: Homepage, https://github.com/griffijf/utfbundle
|
|
6
|
+
Project-URL: Issues, https://github.com/griffijf/utfbundle/issues
|
|
7
|
+
Author-email: Jeff Griffin <pypi@brainslugsolutions.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: archive,bundle,cli,directory,self-extracting,text
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: System :: Archiving
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: pathspec
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# utfbundle
|
|
23
|
+
|
|
24
|
+
Packs a directory of text files into a single Python script, and unpacks
|
|
25
|
+
it back into an identical directory tree.
|
|
26
|
+
|
|
27
|
+
Some workflows only accept a single file, or a single block of pasted
|
|
28
|
+
text (a chat window, a text box in a web tool, a single attachment
|
|
29
|
+
field). If what you want to share is actually a small directory of files
|
|
30
|
+
that's unwieldy: pasting each file in separately loses the directory
|
|
31
|
+
structure, and a zip file isn't always usable in a text-only context
|
|
32
|
+
or readable in aggregate.
|
|
33
|
+
|
|
34
|
+
`utfbundle` writes a directory's contents into one `.py` file, with
|
|
35
|
+
each original file's path and content stored inside it as plain text
|
|
36
|
+
where possible, or base64 for anything that isn't valid UTF-8. Running
|
|
37
|
+
that file writes every file back out, unchanged, to a directory of your
|
|
38
|
+
choosing. Packing and unpacking are inverses. Files come back out
|
|
39
|
+
byte-for-byte identical, whichever form they were stored in.
|
|
40
|
+
|
|
41
|
+
Not intended to be a general-purpose archiver. No compression, no
|
|
42
|
+
encryption. `tar` or `zip` already cover those use cases. This is
|
|
43
|
+
specifically for the case where the constraint is "one plain text file,
|
|
44
|
+
exactly reversible" including directories that have a few binary files
|
|
45
|
+
mixed in with the text.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install utfbundle
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This installs the `utfbundle` command (and pulls in `pathspec` as a
|
|
54
|
+
dependency automatically).
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Python 3.8+
|
|
59
|
+
- [`pathspec`](https://pypi.org/project/pathspec/) (installed automatically with the package above; `pip install pathspec` if running from source)
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
Pack the current directory:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
[python3] utfbundle
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This writes `<dirname>_bundle.py` in the current directory.
|
|
70
|
+
|
|
71
|
+
Reconstruct the tree later by running the bundle:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 <dirname>_bundle.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With no arguments, it writes back into `.`. Packing `.` and running the
|
|
78
|
+
resulting bundle from the same location are complements. Pass a path to
|
|
79
|
+
extract elsewhere instead:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 <dirname>_bundle.py /path/to/restore/into
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You can also pack a specific directory rather than the current one:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
[python3] utfbundle path/to/project -o project_bundle.py
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Here, since a named directory was packed rather than `.`, the bundle's
|
|
92
|
+
default destination is a subdirectory named after it. Running
|
|
93
|
+
`project_bundle.py` with no arguments elsewhere recreates
|
|
94
|
+
`path/to/project/` as `project/`.
|
|
95
|
+
|
|
96
|
+
## Choosing what's included: `.bundlespec`
|
|
97
|
+
|
|
98
|
+
By default, packing includes everything except common junk (`.git/`,
|
|
99
|
+
`node_modules/`, `target/`, `__pycache__/`, `.DS_Store`, prior bundle
|
|
100
|
+
files). To control this directly, add a `.bundlespec` file at the root
|
|
101
|
+
of the directory you're packing.
|
|
102
|
+
|
|
103
|
+
The syntax is `.gitignore`'s glob syntax, inverted. A
|
|
104
|
+
`.gitignore` excludes by default, so a bare line excludes and `!`
|
|
105
|
+
re-includes. A `.bundlespec` includes by default, so a bare line
|
|
106
|
+
**includes**, and `!` **excludes** something an earlier line already
|
|
107
|
+
matched:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
# what to bundle
|
|
111
|
+
src/**
|
|
112
|
+
assets/**
|
|
113
|
+
Cargo.toml
|
|
114
|
+
|
|
115
|
+
# exceptions
|
|
116
|
+
!target/
|
|
117
|
+
!node_modules/
|
|
118
|
+
!*.log
|
|
119
|
+
!secrets.rs
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Rules apply top to bottom; the last matching rule for a given path wins.
|
|
123
|
+
List broad includes first, then narrow with `!`.
|
|
124
|
+
|
|
125
|
+
## Command-line reference
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
[python3] utfbundle [src_dir] [-o bundle.py] [-s SPEC] [-p PATTERN ...] [-n NAME]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Flag | Meaning |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `src_dir` | Directory to pack. Defaults to `.` |
|
|
134
|
+
| `-o, --output` | Output bundle path. Defaults to `<name>_bundle.py` |
|
|
135
|
+
| `-s, --spec` | Path to a patterns file. Defaults to `<src_dir>/.bundlespec` if present |
|
|
136
|
+
| `-p, --pattern` | An extra pattern, may be repeated; applied after the spec file |
|
|
137
|
+
| `-n, --project-name` | Name used for the bundle file and default extract destination |
|
|
138
|
+
|
|
139
|
+
## Limitations
|
|
140
|
+
|
|
141
|
+
- **Binary files are supported, but not free.** Each file is read as
|
|
142
|
+
UTF-8 text; if that fails, it's stored instead as base64 inside the
|
|
143
|
+
bundle (and a note is printed to stderr) and decoded back to exact
|
|
144
|
+
bytes on extract. Either way the round-trip is exact, but base64
|
|
145
|
+
content isn't meant to be read by eye the way the text entries are,
|
|
146
|
+
and it inflates that file's size by roughly a third.
|
|
147
|
+
- **No secret-scanning.** It bundles whatever the patterns match. Keep
|
|
148
|
+
credentials and `.env` files out via `.bundlespec`, the same way you
|
|
149
|
+
would for a commit.
|
|
150
|
+
- **No compression.** Bundle size is roughly the sum of the included
|
|
151
|
+
files' sizes (plus the base64 overhead on any binary files).
|
|
152
|
+
- **Non-UTF-8 text is treated as binary.** A file in Latin-1,
|
|
153
|
+
Windows-1252, UTF-16, etc. will fail the UTF-8 decode check and get
|
|
154
|
+
stored as base64 rather than as a readable literal. It still
|
|
155
|
+
round-trips exactly. This only affects whether it's human-readable
|
|
156
|
+
inside the bundle file.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
utfbundle.py,sha256=RP9Nq_lh4skxwWiDEzKMF_9B0bZhIwRBa0WMb9uiG_Q,12102
|
|
2
|
+
utfbundle-0.1.0.dist-info/METADATA,sha256=tYRZqOyd_DaeXdc6eJvjP0-ybciIf62wk5mWEE-kw2o,5547
|
|
3
|
+
utfbundle-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
4
|
+
utfbundle-0.1.0.dist-info/entry_points.txt,sha256=Q_Amnl9ZDHqalGTd1aGHrH2lTqUBnfJToUozz075ngI,45
|
|
5
|
+
utfbundle-0.1.0.dist-info/licenses/LICENSE,sha256=E8BWYWZHaRzL6TqhGZ1qRgNaD_1nY6A_DFuV8LVH8FA,1069
|
|
6
|
+
utfbundle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeff Griffin
|
|
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.
|
utfbundle.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
utfbundle
|
|
4
|
+
Build a self-extracting bundle script from a source directory tree.
|
|
5
|
+
|
|
6
|
+
The bundle it produces is a drop-in sibling of tectonos_bevy_extract.py:
|
|
7
|
+
a FILES = [(relative_path, kind, content), ...] list plus an extract()
|
|
8
|
+
function, so it can be uploaded as a single plaintext file, or run with
|
|
9
|
+
`python3 bundle.py [dest]` to recreate the tree. `kind` is "text" for
|
|
10
|
+
UTF-8-decodable files (content stored as a readable string literal)
|
|
11
|
+
or "b64" for anything else (content stored as base64, decoded losslessly
|
|
12
|
+
on extract).
|
|
13
|
+
|
|
14
|
+
Requires: pip install pathspec
|
|
15
|
+
|
|
16
|
+
------------------------------------------------------------------------
|
|
17
|
+
USAGE
|
|
18
|
+
------------------------------------------------------------------------
|
|
19
|
+
[python3] utfbundle [src_dir] [-o bundle.py] [-s SPEC] [-p PATTERN ...] [-n NAME]
|
|
20
|
+
|
|
21
|
+
src_dir Directory to pack (default: current directory)
|
|
22
|
+
-o, --output Output bundle path (default: <src_dir_name>_bundle.py)
|
|
23
|
+
-s, --spec Patterns file (default: <src_dir>/.bundlespec if present)
|
|
24
|
+
-p, --pattern Extra pattern, may be repeated; applied after the spec
|
|
25
|
+
-n, --project-name Name for the bundle file/dest (default: src_dir's name)
|
|
26
|
+
|
|
27
|
+
------------------------------------------------------------------------
|
|
28
|
+
PATTERN SYNTAX (gitignore glob syntax, inverted)
|
|
29
|
+
------------------------------------------------------------------------
|
|
30
|
+
A .gitignore's job is to exclude, so a bare pattern there excludes and
|
|
31
|
+
`!` re-includes. A .bundlespec's job is the opposite — to say what to
|
|
32
|
+
include: a bare pattern INCLUDES, and `!` EXCLUDES a match that an
|
|
33
|
+
earlier pattern already included.
|
|
34
|
+
|
|
35
|
+
src/** include everything under src/
|
|
36
|
+
*.rs include any .rs file anywhere
|
|
37
|
+
Cargo.toml include this file at the root
|
|
38
|
+
!src/**/*.test.rs exclude test files a broader include pulled in
|
|
39
|
+
!target/ exclude a directory entirely, even if some
|
|
40
|
+
other pattern would include it
|
|
41
|
+
|
|
42
|
+
Every file starts EXCLUDED. Rules apply top to bottom — spec file first,
|
|
43
|
+
then --pattern flags in order — and the last match wins, so list broad
|
|
44
|
+
includes first and narrow them with `!` afterward.
|
|
45
|
+
|
|
46
|
+
If no .bundlespec is found and no -s is given, the default is:
|
|
47
|
+
**
|
|
48
|
+
!.git/
|
|
49
|
+
!__pycache__/
|
|
50
|
+
!*.pyc
|
|
51
|
+
!target/
|
|
52
|
+
!node_modules/
|
|
53
|
+
!.DS_Store
|
|
54
|
+
!*_bundle.py
|
|
55
|
+
i.e. "include everything except common junk."
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
import argparse
|
|
61
|
+
import base64
|
|
62
|
+
import os
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
from pathspec.patterns.gitwildmatch import GitIgnoreSpecPattern
|
|
66
|
+
|
|
67
|
+
DEFAULT_PATTERNS = [
|
|
68
|
+
"**",
|
|
69
|
+
"!.git/",
|
|
70
|
+
"!__pycache__/",
|
|
71
|
+
"!*.pyc",
|
|
72
|
+
"!target/",
|
|
73
|
+
"!node_modules/",
|
|
74
|
+
"!.DS_Store",
|
|
75
|
+
"!*_bundle.py",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Pattern:
|
|
80
|
+
"""One line of a .bundlespec.
|
|
81
|
+
|
|
82
|
+
Wraps pathspec's gitignore-glob compiler. Its `.include` flag maps
|
|
83
|
+
directly onto our inversion: pathspec sets `include=True` for
|
|
84
|
+
a normal (unprefixed) pattern and `False` for a `!`-prefixed one —
|
|
85
|
+
exactly our own bare=include, `!`=exclude scheme — so
|
|
86
|
+
`self.exclude = not compiled.include` is the entire adapter.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, raw):
|
|
90
|
+
self.raw = raw.rstrip("\n")
|
|
91
|
+
compiled = GitIgnoreSpecPattern(self.raw)
|
|
92
|
+
self.exclude = not compiled.include
|
|
93
|
+
self._regex = compiled.regex
|
|
94
|
+
|
|
95
|
+
def matches(self, relpath):
|
|
96
|
+
return self._regex is not None and self._regex.match(relpath) is not None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_patterns(spec_path, extra_patterns):
|
|
100
|
+
patterns = []
|
|
101
|
+
if spec_path and os.path.isfile(spec_path):
|
|
102
|
+
with open(spec_path, "r", encoding="utf-8") as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line or line.startswith("#"):
|
|
106
|
+
continue
|
|
107
|
+
patterns.append(Pattern(line))
|
|
108
|
+
elif spec_path is None:
|
|
109
|
+
patterns = [Pattern(line) for line in DEFAULT_PATTERNS]
|
|
110
|
+
patterns += [Pattern(p) for p in (extra_patterns or [])]
|
|
111
|
+
return patterns
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_included(relpath, patterns):
|
|
115
|
+
"""Every path starts excluded; the last matching pattern decides."""
|
|
116
|
+
included = False
|
|
117
|
+
for pat in patterns:
|
|
118
|
+
if pat.matches(relpath):
|
|
119
|
+
included = not pat.exclude
|
|
120
|
+
return included
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def dir_is_hard_excluded(rel_dir, patterns):
|
|
124
|
+
"""Whether to prune a directory from the walk entirely.
|
|
125
|
+
|
|
126
|
+
Since the default state is "excluded", we can't prune on "not
|
|
127
|
+
included" — an unmatched directory may still contain files a later
|
|
128
|
+
include pattern pulls in. We only prune when a pattern explicitly
|
|
129
|
+
EXCLUDES the directory itself (a `!some/dir/`-style rule).
|
|
130
|
+
"""
|
|
131
|
+
excluded = False
|
|
132
|
+
for pat in patterns:
|
|
133
|
+
if pat.matches(rel_dir) or pat.matches(rel_dir + "/"):
|
|
134
|
+
excluded = pat.exclude
|
|
135
|
+
return excluded
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def collect_files(src_dir, patterns):
|
|
139
|
+
"""Return sorted list of relative (posix-style) file paths to include."""
|
|
140
|
+
results = []
|
|
141
|
+
for root, dirs, files in os.walk(src_dir):
|
|
142
|
+
rel_root = os.path.relpath(root, src_dir)
|
|
143
|
+
rel_root = "" if rel_root == "." else rel_root.replace(os.sep, "/")
|
|
144
|
+
|
|
145
|
+
kept_dirs = []
|
|
146
|
+
for d in dirs:
|
|
147
|
+
rel_d = f"{rel_root}/{d}" if rel_root else d
|
|
148
|
+
if not dir_is_hard_excluded(rel_d, patterns):
|
|
149
|
+
kept_dirs.append(d)
|
|
150
|
+
dirs[:] = kept_dirs
|
|
151
|
+
|
|
152
|
+
for fname in files:
|
|
153
|
+
rel_f = f"{rel_root}/{fname}" if rel_root else fname
|
|
154
|
+
if is_included(rel_f, patterns):
|
|
155
|
+
results.append(rel_f)
|
|
156
|
+
return sorted(results)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def encode_file_literal(content):
|
|
160
|
+
"""Render `content` as a Python string literal for the bundle.
|
|
161
|
+
|
|
162
|
+
Prefers a readable triple-double-quoted block (backslashes escaped so
|
|
163
|
+
the extracted bytes are exact). Falls back to repr() if the content
|
|
164
|
+
contains a triple-quote or ends in a bare double-quote, either of
|
|
165
|
+
which would break the block form.
|
|
166
|
+
"""
|
|
167
|
+
if '"""' in content or content.rstrip("\n").endswith('"'):
|
|
168
|
+
return repr(content)
|
|
169
|
+
escaped = content.replace("\\", "\\\\")
|
|
170
|
+
return '"""\\\n' + escaped + '"""'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
BUNDLE_HEADER = '''#!/usr/bin/env python3
|
|
174
|
+
"""
|
|
175
|
+
{bundle_name}
|
|
176
|
+
Self-extracting bundle for {project_name}.
|
|
177
|
+
|
|
178
|
+
Usage:
|
|
179
|
+
python3 {bundle_name} # extract into {default_dest_display}
|
|
180
|
+
python3 {bundle_name} <dest> # extract into <dest>/
|
|
181
|
+
|
|
182
|
+
Generated by utfbundle. Text files are stored as plain string literals
|
|
183
|
+
between delimiters. Files that aren't valid UTF-8 are stored as base64 instead ("b64"
|
|
184
|
+
kind) and decoded losslessly on extract, in addition to running this
|
|
185
|
+
script to recreate the tree on disk.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
import base64, os, sys
|
|
189
|
+
|
|
190
|
+
# ===========================================================================
|
|
191
|
+
# FILE BUNDLE
|
|
192
|
+
# Each entry: (relative_path, kind, content)
|
|
193
|
+
# kind "text" -> content is the file's exact text, ready to write as-is
|
|
194
|
+
# kind "b64" -> content is base64; decode to bytes before writing
|
|
195
|
+
# ===========================================================================
|
|
196
|
+
|
|
197
|
+
FILES = [
|
|
198
|
+
'''
|
|
199
|
+
|
|
200
|
+
BUNDLE_FOOTER = ''']
|
|
201
|
+
|
|
202
|
+
# ===========================================================================
|
|
203
|
+
# EXTRACTOR
|
|
204
|
+
# ===========================================================================
|
|
205
|
+
|
|
206
|
+
def extract(dest={default_dest_repr}):
|
|
207
|
+
for rel_path, kind, content in FILES:
|
|
208
|
+
out = os.path.join(dest, rel_path)
|
|
209
|
+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
210
|
+
if kind == "text":
|
|
211
|
+
with open(out, 'w', encoding='utf-8') as f:
|
|
212
|
+
f.write(content)
|
|
213
|
+
else:
|
|
214
|
+
with open(out, 'wb') as f:
|
|
215
|
+
f.write(base64.b64decode(content))
|
|
216
|
+
print(f' wrote {{out}}')
|
|
217
|
+
print(f'Done. {{len(FILES)}} files extracted to {{dest!r}}.')
|
|
218
|
+
|
|
219
|
+
if __name__ == '__main__':
|
|
220
|
+
dest = sys.argv[1] if len(sys.argv) > 1 else {default_dest_repr}
|
|
221
|
+
extract(dest)
|
|
222
|
+
'''
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def write_bundle(out_path, project_name, default_dest, files_with_content):
|
|
226
|
+
bundle_name = os.path.basename(out_path)
|
|
227
|
+
default_dest_display = "./" if default_dest == "." else f"./{default_dest}/"
|
|
228
|
+
with open(out_path, "w", encoding="utf-8") as out:
|
|
229
|
+
out.write(
|
|
230
|
+
BUNDLE_HEADER.format(
|
|
231
|
+
bundle_name=bundle_name,
|
|
232
|
+
project_name=project_name,
|
|
233
|
+
default_dest_display=default_dest_display,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
for rel_path, kind, content in files_with_content:
|
|
237
|
+
out.write(" # " + "-" * 72 + "\n")
|
|
238
|
+
out.write(f" # {rel_path} ({kind})\n")
|
|
239
|
+
out.write(" # " + "-" * 72 + "\n")
|
|
240
|
+
out.write(" (\n")
|
|
241
|
+
out.write(f" {rel_path!r},\n")
|
|
242
|
+
out.write(f" {kind!r},\n")
|
|
243
|
+
if kind == "text":
|
|
244
|
+
literal = encode_file_literal(content)
|
|
245
|
+
# Only the opening marker gets indented — the rest of
|
|
246
|
+
# `literal` is the literal string *value* (it may contain
|
|
247
|
+
# real newlines from the line-continuation trick), so it
|
|
248
|
+
# must not be reindented line-by-line or the indentation
|
|
249
|
+
# would become part of the extracted file's content.
|
|
250
|
+
out.write(" " + literal)
|
|
251
|
+
else: # "b64" — plain repr is fine, it's just an ascii string
|
|
252
|
+
out.write(" " + repr(content))
|
|
253
|
+
out.write(",\n ),\n\n")
|
|
254
|
+
out.write(BUNDLE_FOOTER.format(default_dest_repr=repr(default_dest)))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main():
|
|
258
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
259
|
+
ap.add_argument("src_dir", nargs="?", default=".", help="Directory to pack (default: current directory)")
|
|
260
|
+
ap.add_argument("-o", "--output", help="Output bundle path (default: <src_dir_name>_bundle.py)")
|
|
261
|
+
ap.add_argument("-s", "--spec", help="Patterns file (default: <src_dir>/.bundlespec if present)")
|
|
262
|
+
ap.add_argument("-p", "--pattern", action="append", default=[], help="Extra pattern, may be repeated")
|
|
263
|
+
ap.add_argument("-n", "--project-name", help="Name for the bundle file/dest (default: src_dir's name)")
|
|
264
|
+
args = ap.parse_args()
|
|
265
|
+
|
|
266
|
+
src_dir = os.path.abspath(args.src_dir)
|
|
267
|
+
if not os.path.isdir(src_dir):
|
|
268
|
+
print(f"error: not a directory: {src_dir}", file=sys.stderr)
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
|
|
271
|
+
project_name = args.project_name or os.path.basename(src_dir.rstrip("/")) or "bundle"
|
|
272
|
+
|
|
273
|
+
# Pack and extract are complements: `utfbundle` with no args packs
|
|
274
|
+
# the current directory, so running the resulting bundle with no args
|
|
275
|
+
# writes straight back into the current directory, not a subdirectory
|
|
276
|
+
# one level deeper. Only default to a named subdirectory when a
|
|
277
|
+
# *specific* directory (other than cwd) was packed.
|
|
278
|
+
default_dest = "." if src_dir == os.path.abspath(".") else project_name
|
|
279
|
+
out_path = args.output or f"{project_name}_bundle.py"
|
|
280
|
+
|
|
281
|
+
spec_path = args.spec
|
|
282
|
+
if spec_path is None:
|
|
283
|
+
candidate = os.path.join(src_dir, ".bundlespec")
|
|
284
|
+
spec_path = candidate if os.path.isfile(candidate) else None
|
|
285
|
+
|
|
286
|
+
patterns = load_patterns(spec_path, args.pattern)
|
|
287
|
+
rel_files = collect_files(src_dir, patterns)
|
|
288
|
+
|
|
289
|
+
if not rel_files:
|
|
290
|
+
print("warning: no files matched — check your patterns", file=sys.stderr)
|
|
291
|
+
|
|
292
|
+
files_with_content = []
|
|
293
|
+
for rel in rel_files:
|
|
294
|
+
abs_path = os.path.join(src_dir, rel)
|
|
295
|
+
try:
|
|
296
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
297
|
+
content = f.read()
|
|
298
|
+
files_with_content.append((rel, "text", content))
|
|
299
|
+
except UnicodeDecodeError:
|
|
300
|
+
print(f"stored as binary (not valid UTF-8): {rel}", file=sys.stderr)
|
|
301
|
+
with open(abs_path, "rb") as f:
|
|
302
|
+
raw = f.read()
|
|
303
|
+
content = base64.b64encode(raw).decode("ascii")
|
|
304
|
+
files_with_content.append((rel, "b64", content))
|
|
305
|
+
|
|
306
|
+
write_bundle(out_path, project_name, default_dest, files_with_content)
|
|
307
|
+
print(f"Packed {len(files_with_content)} files from {src_dir!r} into {out_path!r}.")
|
|
308
|
+
print(f" running the bundle with no args extracts into: {default_dest!r}")
|
|
309
|
+
print(f" using spec: {spec_path}" if spec_path else " using built-in default patterns (no .bundlespec found)")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|