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
📖 中文文档: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 |
|---|---|
FBX201100 … FBX201400 |
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:
- Only
session/fbx.pyimportsfbx. schema,selectors,params,serializeanderrorsstay pure.- Operations name no concrete session class — only the protocol.
- 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_versionand drive migrations from it. Pin tofbxkit.SCHEMA_VERSION, not to the pip release — the package ships many times without the document changing. - Store
fileand the scene data separately.fileis disk truth and stays true; what one SDK build made of those bytes is only true for that build. file.sha1works as an idempotency key — skip a file already processed.- Give
countersits 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=Trueto 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a730b5541e6c66519da5ac6bd6211bea6d5709292d069f8b09c07643db4b17ad
|
|
| MD5 |
97f7ed235b36ac205c6eb1d236efada6
|
|
| BLAKE2b-256 |
04d927c2607fc000bfb231a818de135ff4ec9508f97bec7ccc7920ecc6227d75
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71b3f3eb3f64587562b890501454fbcd010cd14cd2feb489166a424149a5611d
|
|
| MD5 |
63c935d6b9d4c50ac7e6c38b654d3794
|
|
| BLAKE2b-256 |
67987e3034e5447cc158c0e618b28661eb5992f8498ca9628674b70d04971f88
|