Python client
Note
Synced from tie/python/README.md on 2026-10-17. Edit the source, not this copy.
A pure-Python client and CLI for the tie triple-store (tie-triplestore)
and content-addressed blob store (tie-filehost). No third-party
dependencies — it uses only the standard library (urllib, tomllib, ssl,
zipfile, struct) and targets Python 3.11+.
The wire format, tag vocabulary, config layout, and directory-manifest bytes
are all matched to the Go client (../client/), so files, tags, and directory
trees written by this client interoperate byte-for-byte with the Go tie CLI
and vice-versa.
Install
cd python
pip install -e . # installs the `tie` console scriptThis registers a tie entry point (tie_client.cli:main). If you already
have the Go tie on your PATH, install into a venv or invoke the module
directly: python -m tie_client.cli ....
Configuration
Config is TOML, matching client.Config in the Go client. It is searched for
by name (default config, .toml appended if missing) in this order:
- the name as a literal path,
./<name>(current directory),$XDG_CONFIG_HOME/tie/<name>(or~/.config/tie/<name>).
Create a localhost default and edit it:
tie conf create # writes ~/.config/tie/config.tomlUsername = 'defaultuser'
Password = 'defaultpassword'
Namespace = 'Collections'
Collection = 'Main'
TripleStoreURL = 'http://localhost:1161'
DefaultFileHosts = ['default']
PrevVersions = 3
[FileHosts.default]
URL = 'http://localhost:1162'
Insecure = false
[ImportDest] # optional: import destination templates per dir-type
audio-dir = '{artist}/{album}'
[Queries] # optional: saved named queriesKey fields:
TripleStoreURL/WebserviceInsecure— triplestore URL;InsecureisInsecureSkipVerifyfor HTTPS (does not swap the scheme). The olderWebservicespelling is still read, but new configs are written withTripleStoreURL, the key the Go tools use.FileHosts.<name>— blob stores, keyed by name. When no--hostis given, the default collection’sFileHostsare used (its own list when a[Collections.<name>]entry sets one, elseDefaultFileHosts).Username/Passwordride every filehost request as Basic Auth.PrevVersions— version-history retention count. Note: a TOML file that omitsPrevVersionsloads0(no history), matching the Go client — onlyconf createseeds the default of3.
Gotcha:
default_config()points atlocalhost:1161/1162. For any real deployment, load an explicit config; do not rely on the built-in default.
CLI
Mirrors the non-FUSE subcommands of the Go cmd/tie (there is no mount — use
the Go binary for FUSE). Pass -c <name> to select a config.
Triples
tie add <key> <value1> <value2> # add a triple (alias: a)
tie get <key> [more...] [-r] [-f REL] # query triples (alias: g)
tie del <key> <value1> <value2> # delete a triple (alias: d)get treats trailing args as extra include-terms (a leading - makes a term
an exclude); any extra term implies --reverse. Flags: -r/--reverse,
-f/--filter, -l/--limit (default 1000), -o/--offset, -s/--sortby.
del supports wildcards: del <key> * * deletes every triple on the key;
del <key> <rel> * deletes every value under one relation. (del <key> * <v2>
is rejected as a likely typo.)
Files
tie upload <path> [--host NAME | --server URL] [--insecure] [--json]
tie download <source-hash> <dest> [--host NAME | --server URL] [--insecure]upload prints <hash>\t<filename> per item (directories upload bottom-up,
one line per child plus the manifest). --json emits a structured result.
download expands directory manifests recursively into dest.
Import
tie import <paths...> [-t TAG]... [--host NAME]... [--dest PATH] [--collection C]
tie import audio-dir <paths...> # label directory roots as audio-dir
tie import image-archive <paths...> # force archive tie-type on rootsimport uploads and tags in one step: sniffs each file’s tie-type (refining
zip archives to media-specific types by their modal content), extracts audio
metadata, builds the tie:-scheme path tree, and reconciles/versions
superseded files. Built-in dir-type subcommands are audio-dir, image-dir,
video-dir, document-dir and the four *-archive types; any [ImportDest]
key in your config adds another subcommand. --dest overrides placement;
otherwise an [ImportDest] template (e.g. {artist}/{album}) or the source
path is used.
Tags
tie tag list [-o OFF] [-l LIM] # registered tag names
tie tag add <tag> # register a name only
tie tag del <tag> # remove the tag everywhere + registry
tie tag rename <old> <new> # rewrite the tag everywhere
tie tag files <tag> # items carrying a tag
tie tag show <hash> # tags on one item
tie tag set <hash> [tags...] # replace an item's tags
tie tag untagged [-t TYPE] # items with a tie-type but no tagTags attach to a content hash, so del/rename/set change the tag wherever
that content appears. untagged uses the server-side MissingRelation
predicate, so only untagged rows cross the wire.
Versions
tie versions list <path> # history in <Collection>_prev
tie versions restore <path> [hash] # restore latest (or a specific) versionBackup
tie dump # every triple as TSV on stdout
tie restore [file] [--drop] # load TSV (stdin if no file); --drop clears firstLibrary
Everything hangs off one TieClient facade (mirroring the Go client — the
high-level tag/dir/import/version/favorite/cotag methods are bound onto the
class at import time). Import from the top-level package:
from tie_client import TieClient, Config, FileHost, QuerySpec, Update
tie = TieClient.from_config_name("config")
tie = TieClient(Config(
username="u", password="p",
namespace="Collections", collection="Main",
webservice="http://host:1161",
default_file_hosts=["main"],
file_hosts={"main": FileHost(url="http://host:1162")},
prev_versions=3,
))Triples & queries
tie.add("key", "tag", "holiday")
row = tie.get("key") # -> Row; raises NotFound if absent
row.values("tag") # ["holiday", ...]
row.first("filename") # first value or ""
rows, total = tie.query(QuerySpec(
terms=["holiday"], filter="tag", reverse=True,
expand=True, limit=50,
))
for k, v1, v2 in tie.dump(): # streamed NDJSON of forward triples
...QuerySpec fields: terms, exclude, scope, missing_relation, filter,
reverse, expand, offset, limit, sort_by. A query matching nothing
raises NotFound (a normal empty-result signal, not a transport error).
Batched writes
b = tie.new_batch() # ordered ops against one collection
b.add(hash, "tag", "beach")
b.delete(hash, "tag", "old")
b.set(hash, "tag-date", ["2026-08-20 ..."])
b.update(Update(hash, "filename", "old.jpg", "new.jpg", add_on_failure=True))
tie.run_batch(b)Files
result = tie.upload("main", "/path/to/file_or_dir") # -> UploadResult
result.items[-1].hash # top-level content hash
tie.download("main", content_hash, "/tmp/out")
fh = tie.filehost(tie.resolve_host("main")) # FilehostClient
fh.upload_bytes(b"...") # raw blob -> hash
fh.download_bytes(hash) # blob bytes (errors on dir)
fh.get_retention(hash) / fh.set_retention(hash, "24h") # expiry controlTables
Store a spreadsheet/CSV as a table entity (ordered headers + rows) and read it back as a grid, without hand-rolling a triple encoding:
uid = tie.insert_table("", ["Name", "Age", "City"],
[["Alice", "30", "NYC"], ["Bob", "25", "LA"]])
headers, rows = tie.read_table(uid) # (["Name","Age","City"], [["Alice",...], ...])
tie.insert_table(uid, headers, new_rows)Empty cells are not stored and read back as "". Headers must be unique and
none may be named tie-type (it would collide with the row type marker); either
case raises an error. This targets sheet-sized
tables (hundreds–low thousands of rows). The wire model is documented in
client/table.go; the same encoding is used by the Go and Risor clients.
For a multi-row (hierarchical) header, pass header rows instead of labels —
row-major, so headers[i][j] is level i of column j, matching how the source
sheet reads:
uid = tie.insert_table("", [["Sample", "20°C", "20°C"],
["", "Replicate 1", "Replicate 2"]],
[["S1", "4.2", "4.4"]])
header_rows, rows = tie.read_table_levels(uid)Header rows must be rectangular, with merged parent cells already forward-filled
and blanks explicit — deciding where a merged cell ends is file-parsing work the
caller owns. Because a header keys every cell triple in its column, each column
still needs one unique string: its non-empty levels joined by \x1f, which is
what read_table returns. Levels may contain neither \x1f nor \x00. A
single header row stores exactly as the flat call does, and read_table_levels
reports a table stored without levels as depth 1, so neither form needs a
migration.
Tags, dirs, imports, versions, favorites, relations
High-level helpers on TieClient include: list_tags, register_tag,
delete_tag, rename_tag, get_tags, set_tags, files_with_tags,
untagged_files; read_tie_dir, mk_tie_dir_all, set_dir_types,
list_dir_types, rename_file, rename_dir; import_file, import_dir;
list_versions, restore_version; list_favorites, register_favorite,
unregister_favorite; cotags_for_query, relate_files, relations_from;
check_index(deep=, repair=) (server-side forward/reverse index cross-check,
returns the report dict).
Errors
All raise subclasses of TieError: Unauthorized (HTTP 401),
NotFound (key has no associated values), ServerError (Success=false
with a real message).
Module map
| Module | Responsibility |
|---|---|
client.py | TieClient core: triples, queries, batches, file up/download; value types (Row, QuerySpec, Update, Batch, TaggedFile, VersionInfo, MediaRelation). |
highlevel.py | Tag/dir/import/version/favorite/cotag/relation methods bound onto TieClient at import. |
transport.py | TripleStoreClient — POST {webservice}/{Id} with Basic Auth, retry+backoff, NDJSON streaming for Dump. |
filehost.py | FilehostClient — PUT /upload, blob GET, recursive dir up/download, retention. |
config.py | Config/FileHost model, TOML load (stdlib tomllib) + hand-rolled writer, search paths. |
vocab.py | Wire vocabulary: Relation, TieType, archive/dir type sets, tie: scheme, tag-date format. |
filetype.py | Magic-byte media-type / tie-type detection (stdlib subset of h2non/filetype). |
metadata_extract.py | Best-effort ID3v2 (MP3) and Vorbis-comment (FLAC) tag reader. |
tiedir.py | The tiedir-v2 directory-manifest format — bytes match the Go implementation exactly so identical trees dedupe. |
errors.py | TieError and subclasses. |
cli.py | argparse-based tie CLI. |
Interop invariants
These are load-bearing for cross-client compatibility — change them only in lockstep with the Go side:
- Triplestore request envelopes match the JSON field casing of each
api/*.gorequest type exactly (some fields PascalCase, some lowercase-tagged). tiedir-v2manifest bytes (tiedir.py) are byte-identical tometadata/tiedir.go— that identity is what makes identical directory trees content-address to the same hash.- The tag/type vocabulary (
vocab.py) mirrors the Go stringers. - Byte verification on download is intentionally omitted, matching the Go mount’s default-off behavior.
Tests
cd python
python -m pytest # unit tests always runtests/test_e2e.py runs against a live test-env (triplestore :2161,
filehost :2162) and skips automatically when the servers are unreachable.
To exercise it, start the sandbox first:
cd ../test-env && ./build.sh && ./start.shThe e2e suite also cross-checks against the Go tie binary built into
test-env/bin/ to confirm both clients agree on the wire.
tests/test_tables.py does the same for tables, which have no CLI surface: it
shells out to tests/gotable.go (a go run fixture, excluded from the build by
an ignore tag) to write a table with the Go client and read it back with the
Python one and vice versa. It uses the testing/testing namespace/collection so
both clients resolve to the same place as client.TestingConfig().