Skip to content

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 script

This 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:

  1. the name as a literal path,
  2. ./<name> (current directory),
  3. $XDG_CONFIG_HOME/tie/<name> (or ~/.config/tie/<name>).

Create a localhost default and edit it:

tie conf create           # writes ~/.config/tie/config.toml
Username   = '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 queries

Key fields:

  • TripleStoreURL / WebserviceInsecure — triplestore URL; Insecure is InsecureSkipVerify for HTTPS (does not swap the scheme). The older Webservice spelling is still read, but new configs are written with TripleStoreURL, the key the Go tools use.
  • FileHosts.<name> — blob stores, keyed by name. When no --host is given, the default collection’s FileHosts are used (its own list when a [Collections.<name>] entry sets one, else DefaultFileHosts). Username/Password ride every filehost request as Basic Auth.
  • PrevVersions — version-history retention count. Note: a TOML file that omits PrevVersions loads 0 (no history), matching the Go client — only conf create seeds the default of 3.

Gotcha: default_config() points at localhost: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 roots

import 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 tag

Tags 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) version

Backup

tie dump                    # every triple as TSV on stdout
tie restore [file] [--drop] # load TSV (stdin if no file); --drop clears first

Library

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 control

Tables

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

ModuleResponsibility
client.pyTieClient core: triples, queries, batches, file up/download; value types (Row, QuerySpec, Update, Batch, TaggedFile, VersionInfo, MediaRelation).
highlevel.pyTag/dir/import/version/favorite/cotag/relation methods bound onto TieClient at import.
transport.pyTripleStoreClient — POST {webservice}/{Id} with Basic Auth, retry+backoff, NDJSON streaming for Dump.
filehost.pyFilehostClient — PUT /upload, blob GET, recursive dir up/download, retention.
config.pyConfig/FileHost model, TOML load (stdlib tomllib) + hand-rolled writer, search paths.
vocab.pyWire vocabulary: Relation, TieType, archive/dir type sets, tie: scheme, tag-date format.
filetype.pyMagic-byte media-type / tie-type detection (stdlib subset of h2non/filetype).
metadata_extract.pyBest-effort ID3v2 (MP3) and Vorbis-comment (FLAC) tag reader.
tiedir.pyThe tiedir-v2 directory-manifest format — bytes match the Go implementation exactly so identical trees dedupe.
errors.pyTieError and subclasses.
cli.pyargparse-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/*.go request type exactly (some fields PascalCase, some lowercase-tagged).
  • tiedir-v2 manifest bytes (tiedir.py) are byte-identical to metadata/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 run

tests/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.sh

The 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().