Skip to main content

fbxkit

Lightweight, general-purpose CRUD for FBX files, built directly on the Autodesk FBX Python SDK.

import fbxkit

with fbxkit.open("hero.fbx") as doc:
    for node in doc.select("**[type=mesh]"):        # 查  read
        print(node.path)
    doc.create("locators", parent="|root")          # 增  create
    doc.rename("**[type=mesh]", pattern="^SM_", replacement="")   # 改  update
    doc.delete("**|*_ref", required=False)          # 删  delete
    doc.save()                                      # same format, same version

Python FBX SDK License

📖 中文文档:README.zh-CN.md


Why this exists

The FBX SDK is a C++ API with a thin Python binding. It is complete, and it is unforgiving: object lifetimes are manual, there is no generic property getter, and several of its answers are quietly wrong in ways that only show up in a pipeline months later. Writing "rename every mesh" against it directly means writing sixty lines and getting three of them subtly wrong.

fbxkit is the thin layer that makes the four ordinary operations ordinary, without becoming a scene-graph framework you have to adopt.

It is deliberately not: a renderer, a mesh-editing library, an animation retargeting tool, or a wrapper that hides the SDK from you. It does addressing, the four CRUD verbs, and getting the file safely back onto disk.


Install

fbxkit itself has zero runtime dependencies:

pip install fbxkit

The Autodesk SDK is a separate install and is not on PyPI. It ships one wheel per CPython minor version and no other will load it — this is the single most common reason import fbx fails:

python -m pip install "C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl"

That wheel is cp311, so it needs Python 3.11 exactly. If your system Python is a different version, make an environment that matches:

uv venv --python 3.11 .venv
uv pip install --python .venv/Scripts/python.exe "C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl"

mayapy works too, and often already matches — Maya 2025/2026 ship Python 3.11, Maya 2027 ships 3.13.

Everything except opening a file works with no SDK at all: the schema, the selectors, every operation, patch validation, and the entire test suite. That is a property of the architecture, not a coincidence — see Why it tests without the SDK.

python -m fbxkit demo       # see the output shape, no SDK, no file
python -m fbxkit doctor     # is the SDK usable here? if not, exactly why

doctor exists because "install the FBX SDK" is not actionable advice on its own. It reports this interpreter's tag, searches the usual install locations, and either prints the exact pip install line for a matching wheel or tells you that the wheel on this machine is for a different Python:

[--] the Autodesk FBX SDK is NOT importable on this interpreter.
     ...
     Found SDK wheels, but none for cp313:
       C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl

Quick start

查 — read

import fbxkit

with fbxkit.open("hero.fbx") as doc:
    doc.select("**[type=mesh]")       # every mesh, at any depth
    doc.select("|root|geo|*")         # direct children of |root|geo
    doc.find("|root|geo|head")        # exactly one, or an error

    doc.node("|root|geo|head")        # NodeInfo: transform, type, properties
    doc.mesh("|root|geo|head")        # MeshInfo: counts, uv sets, ngons
    doc.get("|root|cam", "Lcl Translation")

    doc.settings()                    # up axis, units, fps
    doc.materials(), doc.textures(), doc.takes()
    doc.missing_textures()            # the usual delivery fault

A whole scene as plain, serializable data — the shape a backend stores:

document = fbxkit.read("hero.fbx")          # opens, snapshots, closes
document.counters.triangles
fbxkit.dumps(document)                      # JSON

增删改 — create, update, delete

with fbxkit.open("hero.fbx") as doc:
    doc.create("locators", parent="|root")
    doc.create("head", parent="|root|geo", type="mesh")

    doc.rename("|root|geo", name="geometry")
    doc.rename("**[type=mesh]", pattern="^SM_", replacement="")
    doc.reparent("|root|cam", parent="|root|rig")
    doc.set("|root|cam", "Visibility", 0.0)
    doc.set_transform("|root|cam", translation=[0, 2, -8])
    doc.add_property("**[type=mesh]", "assetId", value="A-42")
    doc.retarget_textures(prefix="//server/proj/textures")

    doc.delete("**|*_ref", required=False)

    doc.save()

Every one of those returns a PatchResult describing what actually changed.


Selectors

Addressing is by path, because FBX object ids are not stable across a save/load cycle — see design decision 1.

|root|geo|head              one exact node
|*                          the direct children of the scene root
|root|*                     the direct children of |root|
|root|**                    |root| itself and every descendant
**|*_L                      any node at any depth whose name ends in _L
head                        shorthand for **|head
|root|**[type=mesh]         every mesh under |root|
**[name~=^bone_\d+$]        regex on the name
|root|*[type!=mesh]         everything under |root| that is not a mesh

* and ? glob within one segment; ** spans any number of segments including none, which is why |root|** selects |root| too — the behaviour you want when deleting a subtree.

Predicate fields are type, name and path; operators are = (glob), != and ~= (regex).

A selector that matches nothing is an error when you are editing. Reading is allowed to answer "none":

doc.select("**[type=light]")                  # []  -- a fair answer
doc.delete("**[type=light]")                  # NoMatch -- you asked to change something
doc.delete("**[type=light]", required=False)  # fine: nothing matched, nothing done

That default is not a style preference. A batch rename whose selector silently matched nothing reports success, changes no files, and nobody finds out until the assets are wrong three steps downstream.


Operations

Nine ship in the box, and the list is deliberately short: each one is a thing you can ask of any FBX file, and each one is simple enough to reason about in a dry run. fbxkit ops --params prints them with their parameters.

Operation What it does
create_node add a node (null, mesh, skeleton, camera, light)
delete_node remove nodes, recursively by default
rename literally (one node) or by regex (many)
reparent move nodes under a new parent
set_property set an existing property
add_property attach a user-defined property — how pipeline metadata rides along
remove_property remove a user-defined one
set_transform local translation / rotation / scaling
retarget_textures rewrite texture paths, by regex or by rebasing

Patches: an edit is data before it is code

The central idea. A rename is not a method call buried in a script; it is a file:

{
  "name": "delivery_cleanup",
  "version": "1",
  "ops": [
    {"op": "rename", "target": "**[type=mesh]", "pattern": "^SM_", "replacement": ""},
    {"op": "delete_node", "target": "**|*_ref", "required": false},
    {"op": "retarget_textures", "prefix": "//server/proj/textures"},
    {"op": "add_property", "target": "**[type=mesh]", "name": "batch", "value": "run-7"}
  ]
}

Which means a studio can keep its conventions in a repository, review a change to them in a pull request, ship them to a workstation without releasing code, and run the identical definition in a pre-publish check on an artist's machine and in the ingest job on the server. A pile of one-off scripts gives you none of that.

fbxkit validate cleanup.json           # catches typos without opening a scene
fbxkit apply hero.fbx -p cleanup.json --dry-run
fbxkit apply hero.fbx -p cleanup.json -o hero_clean.fbx
result = fbxkit.patch_file("hero.fbx", ops, output="hero_clean.fbx")
result.status     # "ok" | "partial" | "error"
result.changed    # how many individual changes were made
result.saved      # whether the file was actually written

Dry run is honest

Every operation works out its changes first and commits them second, so dry_run skips only the commit. The change list is identical either way — a test asserts that for all ten operations, because a dry run that disagrees with the real run is worse than not having one.

ok: 3 operation(s), 5 would change
  [0] rename             ok       2 change(s)
        update  |root|geo|body name: "body" -> "SM_body"
        update  |root|geo|head name: "head" -> "SM_head"
  [1] set_transform      ok       1 change(s)
        update  |root|cam Lcl Translation: [0.0, 10.0, -5.0] -> [0.0, 2.0, -8.0]
  [2] add_property       ok       2 change(s)
        create  |root|geo|body batch: null -> "run-7"

Status is three-valued

ok and error are obvious. partial is the one that earns its place: "eleven of twelve operations applied" is neither success nor failure, and reporting it as either is a lie the caller will act on.

Failures are isolated by default — operation seven raising does not cost you operations eight through twenty, which is what you want sweeping a directory of real assets where one file always has something odd about it.

Setting a value it already holds is a noop, not a change

So a report says what happened, not what was requested — the difference between "47 nodes changed" and "47 matched, 3 changed".

A failed patch is never written

atomic=True (the default for patch_file and apply_and_save) stops at the first failure. It is not a rollback of the in-memory scene; it is a refusal to save it. That is where atomicity lives: the file on disk is either fully patched or untouched.

result = fbxkit.patch_file("hero.fbx", [
    {"op": "create_node", "name": "ok", "parent": "|root"},
    {"op": "rename", "target": "|does|not|exist", "name": "x"},
])
result.status   # "partial"
result.saved    # False -- hero.fbx is byte-identical to before

Command line

fbxkit info hero.fbx                    # file facts and scene counters
fbxkit ls hero.fbx "**[type=mesh]"      # list nodes matching a selector
fbxkit ls hero.fbx --long               # with types and transforms
fbxkit read hero.fbx -o scene.json      # the whole scene as JSON
fbxkit apply hero.fbx -p patch.json     # apply a patch
fbxkit do hero.fbx rename --param "target=**[type=mesh]" --param "pattern=^SM_"
fbxkit ops --params                     # the operation interface, self-describing
fbxkit validate patch.json              # check a patch, no file needed
fbxkit demo                             # the output shape, no SDK needed
fbxkit doctor                           # is the SDK installed here? which wheel?

Exit codes carry the answer, so this drops into CI without anything parsing the output:

code meaning
0 did what was asked
1 ran, but not everything applied
2 could not run as asked: bad usage, unopenable file, missing SDK

The distinction between 1 and 2 is the one a gate needs. Both stop the pipeline; they need different people to look at them.


Key design decisions

Each of these came out of a measurement, not a preference. The measurements are reproducible against FBX SDK 2020.3.10.

1. Identity is the path, because ids do not survive the file

FbxObject::GetUniqueID() is a session handle, not a persistent id. Measured: a node exported with id 27 came back as 69 after a save and reload in a fresh manager.

So an id cannot be written into a patch today and used to find the same node tomorrow. Every relationship fbxkit exposes is expressed as a path, and the selector language exists to make paths pleasant to write.

This is the sharpest difference from a Maya-side tool, where cmds.ls(uuid=True) gives you an id that lives inside the file and survives renaming and reparenting.

2. A path is not automatically unique, and pretending otherwise edits the wrong node

Three things the SDK accepts, all of which survive a save/load round trip:

what what it breaks
two siblings with the same name FindChild returns the first; the second is unaddressable by name
| inside a node name it is the path separator
an empty name leaves a hole where a path segment should be

fbxkit reports each as a diagnostic, escapes the separator, and mints a #N disambiguator so nothing becomes unreachable:

|root|dup      the first
|root|dup#1    the second
|root|#0       the child with no name

Reading through an ambiguous path gives you the first, with a diagnostic saying others exist. Writing through one raises AmbiguousMatch — because reading the wrong node is recoverable and editing it is not.

3. GetFileFormat() reports binary for ASCII files

Measured: a file this package had just written as ASCII came back from FbxImporter.GetFileFormat() as format 0, "FBX binary". A library that trusts it converts every ASCII FBX in a pipeline to binary the first time anything touches one.

So the format is read from the file's own header bytes, and saves preserve the input's format by default.

4. SetFileExportVersion() returns True for versions that do not exist

Measured: SetFileExportVersion("FBX_BOGUS") returned True, and the file was written at the default 7.7.0 with no error anywhere. A typo would silently promote a 2014 file to 2020.

fbxkit checks the string against a whitelist built by writing one file per candidate and reading the version back:

constant file version
FBX201100FBX201400 7.1 … 7.4
FBX201600, FBX201800 7.5
FBX201900, FBX202000 7.7

(FBX201000 is absent: it is accepted and silently produces 7.7.)

Saves preserve the input's version by default, so an ASCII 2014 file edited with fbxkit comes back as an ASCII 2014 file.

Also measured, and easy to get backwards: SetFileExportVersion must be called after Initialize, or it is discarded silently.

5. Non-recursive delete strands the children in the file

Measured on a subtree of three nodes: after node.Destroy(False) the DAG walk showed the subtree gone, and scene.GetNodeCount() had dropped by exactly one. The children were still in the scene, unparented and invisible to a traversal — and the exporter writes them back out as free-floating objects.

Deletion is therefore recursive by default. recursive=false still exists, because the SDK offers it, but it emits a diagnostic naming how many nodes it just stranded.

6. The SDK gives one error message for four different problems

A missing file, an empty file, a truncated file and a file full of unrelated bytes all produce:

None of the registered readers can process the file

The most common of those is a wrong path, and that message sends people looking in the wrong place. fbxkit checks the disk first:

cannot open hero.fbx: no such file
cannot open empty.fbx: the file is empty (0 bytes)
cannot open notes.fbx: the first bytes match neither a binary nor an ASCII FBX header,
                       so this is probably not an FBX file
cannot open cut.fbx:   the header looks like binary FBX, so it is most likely
                       truncated or corrupt

7. "It opened" does not mean "it is intact"

The one damaged file the SDK does not reject: a truncated ASCII FBX imports without complaint and yields a scene with zero nodes. Measured at 200, 2000 and 6000 bytes of a valid ASCII file — all three opened, all three empty. A truncated binary file is refused outright, so the two halves of the format behave oppositely.

That makes an empty result ambiguous between "this asset is empty" and "this asset is damaged", and nothing downstream can tell them apart. So a scene with no nodes carries a diagnostic saying exactly that:

[warning] scene.empty: the file opened but contains no nodes; a truncated
          ASCII FBX opens this way, so this may be a damaged file rather than
          an empty scene

fbxkit cannot turn this into an error — an empty FBX is legal — but it will not let it pass silently either.

8. Writes are atomic, and that has one honest cost

Exporting straight over the input means a crash, a full disk or a Ctrl-C leaves a truncated file where the asset used to be. Every save goes to a temporary file in the same directory and is moved into place only once the exporter has finished — and a save that reports success while writing zero bytes is refused rather than allowed to clobber.

The cost: the exporter stamps the path it was given into the file, as DocumentUrl/SrcDocumentUrl, so a file written this way records the temporary name rather than its own. There is no way around it — setting FbxDocumentInfo.Url before or after Initialize() is ignored, and so is FbxExporter.SetUrl() (all measured). fbxkit takes the trade deliberately: DocumentUrl is already wrong in any file that has ever been copied, moved or renamed, while a half-written export destroys the asset. The temporary name is derived from the target (.hero.fbxkit-a1b2c3.fbx) so the recorded path at least lands in the right directory and is recognisably fbxkit's.

Related, and worth knowing before you put FBX files under version control: FBX export is not reproducible. Saving the identical scene twice produces different bytes, because the header carries a creation timestamp and the document path. Measured: 14 of 16 fixtures differed on a second generation. Do not expect content-addressed deduplication to work on them.

9. Lifetime is owned by a context manager

An FbxManager that is never destroyed leaks the whole scene. One destroyed twice takes the interpreter down with it. And after any Destroy, every Python wrapper for the freed object dangles — touching one segfaults rather than raising, which is a genuinely unpleasant thing to debug.

So fbxkit.open() is a context manager, close() is idempotent, and nothing that crosses the session boundary is a live SDK pointer. Using a closed document raises DocumentClosed instead of crashing.

10. Caller mistakes raise; file problems travel in a report

A misspelled parameter, an unknown operation, a selector that cannot parse — those are bugs in the calling code, and they raise where the traceback still points at the mistake:

>>> fbxkit.patch_file("hero.fbx", [{"op": "rename", "target": "|a", "nme": "b"}])
ParamError: rename got unknown parameter 'nme'; it accepts: target, required,
            name, pattern, replacement

That matters more in an editing tool than in a read-only one. A scan that ignores a typo returns slightly wrong data; an edit that ignores one writes a file without the change and reports success.

A patch is validated before the file is opened, so a typo costs milliseconds rather than a forty-second import.

11. Batch work stays linear

The obvious implementation caches a path index and throws it away after every write. It is correct and quadratic: each write invalidates, and the next write's resolve() rebuilds the whole thing. Building a thousand-node rig that way costs a million path computations.

So walking is a generator, and the lookup dict is updated in place by writes that can say exactly what changed, and dropped only by writes that cannot (renaming an interior node, reparenting). Creating, renaming leaves and deleting in bulk are all linear. A 6000-node chain builds in well under a second, and the walk is iterative so a deep rig never finds the recursion limit.


Architecture

┌────────────────────────────────────────────────────────────┐
│ schema.py     the data contract -- pure dataclasses         │  ← what a backend stores
│ selectors.py  path patterns -- pure string and tree logic   │
│ params.py     validation, coercion, self-description        │
│ serialize.py  JSON both ways, lossless                      │
├────────────────────────────────────────────────────────────┤
│ ops.py        Operation base class + registry               │  ← the extension point
│ builtin_ops.py  the ten shipped operations                  │
│ patch.py      patches as data; apply, isolate, report       │
├────────────────────────────────────────────────────────────┤
│ session/protocol.py   everything an operation may call      │  ← the boundary
│ session/fbx.py        the ONLY import fbx in the package    │
│ session/fake.py       the in-memory stand-in                │
├────────────────────────────────────────────────────────────┤
│ document.py   lifetime and the facade                       │
│ io.py         disk facts, format sniffing, atomic writes    │
│ cli.py        the shell interface                           │
└────────────────────────────────────────────────────────────┘

Dependencies point one way only. Four rules are enforced by AST scans in tests/test_layering.py rather than left to discipline:

  1. Only session/fbx.py imports fbx.
  2. schema, selectors, params, serialize and errors stay pure.
  3. Operations name no concrete session class — only the protocol.
  4. Nothing imports a third-party package.

Why it tests without the SDK

Operations talk only to SessionProtocol. FakeSession implements the same interface over a plain Python tree — including the awkward parts of the contract, like permitting duplicate sibling names — so:

python -m pytest tests -q       # 273 passed, no SDK, any interpreter

Both sessions share the path-index and resolution code, so the rules that matter (what a missing node does, what an ambiguous one does) cannot drift apart between them. A test that passes against the fake means something.


Extending

Four steps: subclass Operation, give it a params dataclass, @register it, make sure the module is imported. Nothing in fbxkit changes.

from dataclasses import dataclass
from fbxkit import Operation, register
from fbxkit.ops import TargetedParams, select_targets
from fbxkit.schema import Change


@dataclass
class TagParams(TargetedParams):          # inherits target + required
    tag: str = ""

    PARAM_HELP = dict(TargetedParams.PARAM_HELP, tag="the value to write")


@register
class TagOp(Operation):
    name = "tag"
    summary = "Stamp a pipeline tag onto matching nodes"
    params_class = TagParams

    def run(self, session, params, ctx):
        changes = []
        for ref in select_targets(session, params.target, params.required):
            if not ctx.dry_run:
                session.create_property(ref.path, "pipelineTag", "String", params.tag)
            changes.append(Change(kind="create", target=ref.path,
                                  detail="pipelineTag", after=params.tag))
        return changes

It immediately works from Python, from a patch file, and from the command line, with its parameters self-describing in fbxkit ops --params — and it is testable against FakeSession with no SDK.

register replaces on a duplicate name, so registering your own class with name = "rename" swaps out the builtin entirely.

A complete runnable example is in examples/custom_operation.py.

Serving the interface to a frontend

fbxkit.available()      # every operation and parameter, as JSON-safe data

Generate a form or validate a request from that rather than transcribing the parameter list on both sides and letting the copies go stale.


Notes for a backend

  • Store schema_version and drive migrations from it. Pin to fbxkit.SCHEMA_VERSION, not to the pip release — the package ships many times without the document changing.
  • Store file and the scene data separately. file is disk truth and stays true; what one SDK build made of those bytes is only true for that build.
  • file.sha1 works as an idempotency key — skip a file already processed.
  • Give counters its own table. A list page reads only that, instead of touching tens of thousands of node rows.
  • Store PatchResult.operations. "the rename failed" and "there was nothing to rename" are completely different facts, and a consumer that only has the change count cannot tell them apart.
  • Round trips are lossless. A document written by a newer fbxkit carries fields this one has never heard of, and load-then-dump keeps them — including fields nested inside a payload it does know, which is the case that would otherwise silently drop a column.
  • A future MAJOR schema is refused, not guessed at. Pass allow_future=True to read it anyway, explicitly, for a salvage job.

Verification

Everything below was run against FBX SDK 2020.3.10 on Windows, Python 3.11.

Check Result
Unit tests, no SDK present 273 passed, on Python 3.8 through 3.13
Build a scene from nothing, save as ASCII/FBX2014, reopen every node, transform and custom property intact
Edit and save, no format or version given ASCII 7.4.0 in, ASCII 7.4.0 out
Custom String property through a full save/reload survives
GetUniqueID() across a save/reload changes (27 → 69) — hence path identity
Destroy(False) on a 3-node subtree 2 nodes stranded in the scene, still exported
SetFileExportVersion("FBX_BOGUS") returns True, writes the default version
GetFileFormat() on an ASCII file reports binary
Open a missing / empty / truncated-binary / garbage file each classified with its own reason; SDK gives one message for all four
Open a truncated ASCII file opens clean with 0 nodes at 200/2000/6000 bytes — flagged as scene.empty
Failing patch against a real file status="partial", saved=False, file sha1 unchanged
Dry run vs real run, all 10 operations identical change lists; scene untouched by the dry run
6000-node chain: build, index, resolve, walk linear, no recursion limit
Duplicate sibling names / | in a name / empty name all three round-trip through a file; all three diagnosed and addressable
16-file corpus published to SVN and checked out fresh all 16 byte-identical; all 16 match their recorded behaviour
Regenerating the identical corpus 14 of 16 files differ — FBX export is not reproducible
doctor on an interpreter without the SDK finds the cp311 wheel on the machine and explains why cp313 cannot use it

The test corpus

scripts/make_test_fbx.py generates a set of FBX files covering every edge case in Key design decisions — ASCII and binary, file versions 7.4/7.5/7.7, duplicate sibling names, a name containing the path separator, an empty name, a 400-deep hierarchy, an empty scene, a missing texture, and four kinds of damaged file.

The point is the manifest.json beside them. For every file it records what the file pins down, its SHA-1, and what opening it actually did — node count, diagnostics, or the refusal reason. Observed, not asserted, so the corpus can be checked by machine rather than read as prose:

python scripts/make_test_fbx.py  out/       # generate (needs the SDK)
python scripts/verify_testdata.py out/      # bytes and behaviour vs the manifest
python scripts/verify_testdata.py out/ --bytes-only   # checksums only, no SDK

They are kept out of git (.gitignore excludes *.fbx) and published to Subversion instead, which suits regenerated binaries better than a system built around diffing text:

python scripts/publish_testdata.py --dry-run
python scripts/publish_testdata.py

That script is idempotent: it creates the target directory on first run, syncs, and commits only when something differs. Set FBXKIT_TESTDATA_URL to point it at your own repository. Note the reproducibility caveat in decision 8 — regenerating produces different bytes every time, so prefer publishing an existing corpus over regenerating one you have not changed.


Not included, on purpose

  • Mesh editing — creating geometry, welding, triangulating. That is a different library with a different shape.
  • Animation curves. Takes are listed; keyframes are not read or written.
  • Skinning and blend shape weights. Reading them is plausible; editing them belongs somewhere that can afford per-vertex work.
  • Axis/unit conversion. Scene settings can be read, never written. Changing the declared up axis or unit scale without re-transforming the geometry is half a conversion — and the half that silently misplaces every asset that trusts the declaration. A real conversion is a much bigger job than an MVP should pretend to cover, so fbxkit does not offer a half-done version of it under a name that sounds complete.
  • Diffing two files. The document is already a stable shape to diff; the comparison engine is not written yet.

Layout

src/fbxkit/
├── __about__.py        the single definition of the version
├── __init__.py         the public API
├── cli.py              the command line
├── document.py         FbxDocument: lifetime and facade
├── errors.py           the FbxkitError hierarchy
├── io.py               disk facts, format sniffing, version map, atomic writes
├── ops.py              Operation base class + registry
├── builtin_ops.py      the ten shipped operations
├── params.py           parameter validation, coercion, self-description
├── patch.py            patches as data; apply, isolate, report
├── schema.py           the data contract (pure dataclasses)
├── selectors.py        the path pattern language
├── serialize.py        JSON both ways, lossless
└── session/
    ├── protocol.py     SessionProtocol -- all an operation may call
    ├── index.py        paths, disambiguation, collision diagnostics
    ├── base.py         lookup caching and resolution, shared by both sessions
    ├── fbx.py          the real SDK -- the only import fbx
    └── fake.py         the no-SDK stand-in + the demo scene

__about__.py is the single definition of the version; pyproject.toml reads it via dynamic = ["version"], and tests/test_packaging.py fails if a hard-coded copy reappears.


Releasing

The version lives in exactly one place, src/fbxkit/__about__.py. Change it there and nowhere else — tests/test_packaging.py fails if a hard-coded copy reappears.

Publishing goes through one command, which puts the whole gate in front of the upload:

python scripts/release.py --check    # every check and the build, no upload
python scripts/release.py --test     # to TestPyPI
python scripts/release.py            # to PyPI, for real

Running twine upload by hand skips all of it. What it checks, and why each one is there:

Check Why
the version is already on the index The one mistake that cannot be undone. A version number is spent the moment it is uploaded; deleting the release does not give it back. You would have to burn 0.2.1 to fix a bad 0.2.0.
the working tree is clean and pushed the tag has to point at a commit other people can fetch
v<version> is not already a tag catches a re-run of a release that half-failed
CHANGELOG.md has an entry a release whose notes nobody can read is one nobody can evaluate
dist/ is emptied before building twine upload dist/* would otherwise sweep up a stale wheel from an earlier version
every artifact in dist/ matches the version same trap, from the other direction
twine check PyPI rejects a package whose README will not render, and the upload is too late to find out
scripts/check.py tests on Python 3.8–3.13, lint, a build, and a clean-venv install driven through the wheel

Every check runs before any verdict, so one attempt tells you everything that needs fixing rather than making you re-run it to discover the second problem.

Then it asks you to type the version number rather than answer y/n — a y/n prompt gets answered by reflex. On success it tags v<version> and pushes the tag.

Add --sdk <python-with-the-fbx-sdk> to include the SDK round trip in the gate; without it that one check is skipped, because CI cannot run it either.

Credentials stay twine's business. The script never reads, stores or passes a token. Use ~/.pypirc, or TWINE_USERNAME=__token__ with TWINE_PASSWORD=pypi-..., or let twine prompt you.


Credits

The layering — a pure schema, a session protocol with a fake behind it, a registry as the extension point, self-describing parameters, and architecture rules enforced by AST scans — follows the design of mayakit, which does the read-only side of the same problem for Maya scenes.


License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fbxkit-0.1.0.tar.gz (106.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fbxkit-0.1.0-py3-none-any.whl (84.8 kB view details)

Uploaded Python 3

File details

Details for the file fbxkit-0.1.0.tar.gz.

File metadata

  • Download URL: fbxkit-0.1.0.tar.gz
  • Upload date:
  • Size: 106.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.13

File hashes

Hashes for fbxkit-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a730b5541e6c66519da5ac6bd6211bea6d5709292d069f8b09c07643db4b17ad
MD5 97f7ed235b36ac205c6eb1d236efada6
BLAKE2b-256 04d927c2607fc000bfb231a818de135ff4ec9508f97bec7ccc7920ecc6227d75

See more details on using hashes here.

File details

Details for the file fbxkit-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: fbxkit-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 84.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.13

File hashes

Hashes for fbxkit-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 71b3f3eb3f64587562b890501454fbcd010cd14cd2feb489166a424149a5611d
MD5 63c935d6b9d4c50ac7e6c38b654d3794
BLAKE2b-256 67987e3034e5447cc158c0e618b28661eb5992f8498ca9628674b70d04971f88

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page