Packs a directory of text files into a single Python script, and unpacks it back into an identical directory tree.
Useful for uploading a folder to GitHub Gist, ChatGPT, Claude Projects, or any other tool that only accepts flat files, single files, or text blocks. These interfaces typically flatten directory structure on upload, so anything nested under subdirectories loses its path once it's in context.
Reaching for an archiver might be an option in some of these cases, unless text files inside the archive need to be read by a bot or AI integration.
utfbundle writes a directory's contents into one .py file, with
each original file's path and content stored inside it as plain text
where possible, or base64 for anything that isn't valid UTF-8. Running
that file writes every file back out, unchanged, to a directory of your
choosing. Packing and unpacking are inverses. Files come back out
byte-for-byte identical, whichever form they were stored in.
Not intended to be a general-purpose archiver. No compression, no
encryption. tar or zip already cover those use cases. This is
specifically for the case where the constraint is "one plain text file,
exactly reversible" including directories that have a few binary files
mixed in with the text.
pip install utfbundleThis installs the utfbundle command (and pulls in pathspec as a
dependency automatically).
- Python 3.8+
pathspec(installed automatically with the package above;pip install pathspecif running from source)
Pack the current directory:
[python3] utfbundleThis writes <dirname>_bundle.py in the current directory.
Reconstruct the tree later by running the bundle:
python3 <dirname>_bundle.pyWith no arguments, it writes back into .. Packing . and running the
resulting bundle from the same location are complements. Pass a path to
extract elsewhere instead:
python3 <dirname>_bundle.py /path/to/restore/intoYou can also pack a specific directory rather than the current one:
[python3] utfbundle path/to/project -o project_bundle.pyHere, since a named directory was packed rather than ., the bundle's
default destination is a subdirectory named after it. Running
project_bundle.py with no arguments elsewhere recreates
path/to/project/ as project/.
By default, packing includes everything except common junk (.git/,
node_modules/, target/, __pycache__/, .DS_Store, prior bundle
files). To control this directly, add a .bundlespec file at the root
of the directory you're packing.
The syntax is .gitignore's glob syntax, inverted. A
.gitignore excludes by default, so a bare line excludes and !
re-includes. A .bundlespec includes by default, so a bare line
includes, and ! excludes something an earlier line already
matched:
# what to bundle
src/**
assets/**
Cargo.toml
# exceptions
!target/
!node_modules/
!*.log
!secrets.rs
Rules apply top to bottom; the last matching rule for a given path wins.
List broad includes first, then narrow with !.
[python3] utfbundle [src_dir] [-o bundle.py] [-s SPEC] [-p PATTERN ...] [-n NAME]
| Flag | Meaning |
|---|---|
src_dir |
Directory to pack. Defaults to . |
-o, --output |
Output bundle path. Defaults to <name>_bundle.py |
-s, --spec |
Path to a patterns file. Defaults to <src_dir>/.bundlespec if present |
-p, --pattern |
An extra pattern, may be repeated; applied after the spec file |
-n, --project-name |
Name used for the bundle file and default extract destination |
- Binary files are supported, but not free. Each file is read as UTF-8 text; if that fails, it's stored instead as base64 inside the bundle (and a note is printed to stderr) and decoded back to exact bytes on extract. Either way the round-trip is exact, but base64 content isn't meant to be read by eye the way the text entries are, and it inflates that file's size by roughly a third.
- No secret-scanning. It bundles whatever the patterns match. Keep
credentials and
.envfiles out via.bundlespec, the same way you would for a commit. - No compression. Bundle size is roughly the sum of the included files' sizes (plus the base64 overhead on any binary files).
- Non-UTF-8 text is treated as binary. A file in Latin-1, Windows-1252, UTF-16, etc. will fail the UTF-8 decode check and get stored as base64 rather than as a readable literal. It still round-trips exactly. This only affects whether it's human-readable inside the bundle file.
MIT