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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ utfbundle = utfbundle:main
@@ -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()