bethkit.py
Python bindings for bethkit, a Rust library for reading and writing Bethesda plugin and archive files. Python provides typed views and editing workflows; Bethkit remains responsible for binary formats, schema interpretation, and lossless writing.
Python 2.2.0 uses Bethkit 0.7.0, pinned together with its published release archives in native-source.json. This version supports mixed inline encodings and exact per-field choices for ambiguous text. See BUILDING.md for source builds and release gates.
Features
- Plugin reading - open
.esp/.esm/.eslfiles by path or from in-memory bytes; iterate groups and records; look up records by FormID or EditorID; inspect sub-records - Plugin writing - build new plugins from scratch with
PluginWriter,WritableGroup, andWritableRecord - BSA / BA2 archives - open and extract entries from BSA (TES4/SSE) and BA2 (GNRL/DX10) archives; write new archives with
BsaWriter,Ba2GnrlWriter, andBa2Dx10Writer - String tables - read, edit, and write
.STRINGS/.DLSTRINGS/.ILSTRINGSlocalisation files; apply translation sets withLocalizationSet - Typed records - schema-generated, immutable models for all 134 Skyrim SE record definitions, with nested Quest stages, dialogue responses, and perk effects; models load only when requested.
- Semantic editing - edit typed fields through structural addresses, validate records, and patch existing plugins while preserving untouched record bytes.
- Localization - enumerate inline and external text by position, isolate changes to shared string IDs, and save a plugin together with its tables as a new output bundle.
- Load-order utilities -
LoadOrder,GlobalFormId, andPluginCachefor winning-override lookups and EditorID search across multiple plugins
Requirements
| Requirement | Version |
|---|---|
| Python | ≥ 3.10 |
| pydantic | ≥ 2.5.3, < 3 |
Release wheels bundle the matching native library with the Skyrim SE schema only. Access the embedded package through SchemaCatalog.embedded() and catalog.package(Game.SKYRIM_SE), or open an explicit .bkschema file as in the examples below. Local development requires the pinned native build. Generated types also check the exact schema payload hash; an ABI-compatible library alone does not make a different schema compatible.
Schema definitions originate in xEdit and are exported and published by Modding-Forge/xDump. See SCHEMA.md for coverage, provenance, generation, and current limitations.
Installation
uv add bethkit
or
pip install bethkit
Quick Start
Reading a plugin
from pathlib import Path
from bethkit import Plugin, Game
with Plugin.open(Path("Ordinator - Perks of Skyrim.esp"), Game.SKYRIM_SE) as plugin:
print("Masters:", plugin.masters)
print("Kind:", plugin.kind)
for group in plugin:
for child in group:
if hasattr(child, "form_id"):
print(f" 0x{child.form_id:08X} {child.editor_id}")
Reading and editing a typed quest
from pathlib import Path
from bethkit import Game, Plugin, PluginPatcher, SchemaPackage, SemanticContext
from bethkit.records.skyrim_se import QuestRecord
with SchemaPackage.open(Path("skyrim_se.bkschema")) as package:
with SemanticContext(package) as context:
with Plugin.open(Path("MyMod.esp"), Game.SKYRIM_SE) as plugin:
record = plugin.find_record(0x800)
if record is None:
raise LookupError("Quest 0x800 was not found")
quest = QuestRecord.from_record(
record, context, localized=plugin.is_localized
)
for stage in quest.stages:
for entry in stage.log_entries:
print(entry.log_entry)
with context.edit(record, localized=plugin.is_localized) as editor:
editor.set(quest.field("editor_id"), "MyRenamedQuest")
with editor.finish() as replacement:
with PluginPatcher(plugin) as patcher:
patcher.replace_record(record.form_id, replacement)
patcher.write_to_file(Path("MyMod-edited.esp"))
The generated view is read-only and remains usable after its source handles close. Edits use the native editor, including schema validation and callbacks. Structural changes require fresh addresses from editor.snapshot(); ordinary scalar or text-length changes retain their positions. The generic RecordView and SemanticContext.snapshot() remain available for inspectors and unknown fields.
Editing localized text safely
from pathlib import Path
from bethkit import (
Game, LocalizationEditor, LocalizationSet, Plugin,
SchemaPackage, SemanticContext,
)
source = Path("MyLocalizedMod.esp")
with SchemaPackage.open(Path("skyrim_se.bkschema")) as package:
with SemanticContext(package) as context:
with Plugin.open(source, Game.SKYRIM_SE) as plugin:
with LocalizationSet.open(source, "english") as tables:
with LocalizationEditor(plugin, context, tables) as editor:
for string in editor.iter_strings():
if string.text == "Old text":
editor.replace(string, "New text")
editor.save_bundle(
Path("edited-bundle"), source.name, language="english"
)
For an inline-string plugin, omit tables. A string's identity contains its plugin and structural position, never its text or external table ID. Replacing one external string allocates a fresh ID so other users of a shared ID are unchanged. Saving creates a new directory containing the plugin, its Strings/ files, and a checksum manifest; it never overwrites an installed Data directory.
Bethkit normally uses each schema field's declared encoding for inline text. For a plugin known to use UTF-8 throughout its translatable inline fields, SemanticContext(package, inline_encoding="utf-8") remains an explicit override. For plugins mixing UTF-8 and legacy text, use SemanticContext(package, inline_decoding="prefer_utf8"). This opt-in mode checks each translatable inline string separately, falling back to the schema codec when its bytes are not valid UTF-8. StringReference.encoding and encoding_source report the selected codec and whether it came from the schema or the heuristic. Valid UTF-8 bytes can also be valid legacy bytes, so automatic selection cannot resolve every ambiguous string. For such a field, LocalizationEditor.select_inline_encoding(reference, "cp1252") re-decodes it without changing bytes; replace(reference, text, inline_encoding="cp1252") selects the codec for an atomic edit. Technical fields and external string tables are unaffected. Mixed decoding requires Bethkit 0.7.0 or newer.
Building a plugin from scratch
from pathlib import Path
from bethkit import Game, PluginWriter, WritableGroup, WritableRecord
with PluginWriter(Game.SKYRIM_SE) as writer:
with WritableGroup.new(b"NPC_") as group:
rec = WritableRecord.new(b"NPC_", form_id=0x000D62)
rec.add_subrecord(b"EDID", b"MyNPC\x00")
group.add_record(rec)
writer.add_group(group)
writer.write_to_file(Path("MyMod.esp"))
Extracting from an archive
from pathlib import Path
from bethkit import Archive
with Archive.open(Path("Skyrim - Meshes.bsa")) as arc:
data = arc.extract("meshes/actors/character/character assets/skeleton.nif")
if data:
Path("skeleton.nif").write_bytes(data)
Load-order and FormID resolution
from pathlib import Path
from bethkit import Game, Plugin, PluginCache, PluginKind, LoadOrder
lo = LoadOrder()
lo.push("Skyrim.esm", PluginKind.FULL)
lo.push("MyMod.esp", PluginKind.FULL)
cache = PluginCache()
cache.add("Skyrim.esm", Plugin.open(Path("Skyrim.esm"), Game.SKYRIM_SE))
hit = cache.find_by_editor_id("ArmorIronCuirass")
if hit:
print(hit.global_form_id) # Skyrim.esm:0x012E49
print(hit.record.editor_id)
Development
git clone https://github.com/Modding-Forge/bethkit.py
cd bethkit.py
uv sync --extra dev
uv run pytest
Linting and type-checking:
uv run ruff check src/ tests/ scripts/
uv run ruff format --check src/ tests/ scripts/
uv run pyright src/ tests/ scripts/
Unit tests mock the native boundary. A release-quality check must also set BETHKIT_LIB and BETHKIT_SCHEMA and run pytest --require-native; an explicitly configured but incompatible library is an error, not a skipped test. See BUILDING.md for the pinned workflow and SCHEMA.md for deterministic model generation.
Contributions follow the Python style conventions. Maintained files are limited to 400 lines, enforced by local commit/push hooks, CI, and the build backend; individual generated-file exceptions are recorded explicitly in file-length-policy.json. Install the local guards with uv run python scripts/install_git_hooks.py after setting up the development environment.
Related projects
- bethkit - the underlying Rust library;
bethkit.pywraps its C ABI
License
Apache-2.0 - see LICENSE.
Metadata
Release files for bethkit 2.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bethkit-2.2.0-py3-none-win_amd64.whl | Python 3 | none | Windows x86-64 | Details |
| bethkit-2.2.0-py3-none-manylinux_2_34_x86_64.whl | Python 3 | none | Linux glibc 2.34+ x86-64 | Details |
Total release size: 6.0 MB
Release files / bethkit-2.2.0-py3-none-win_amd64.whl
| Download URL | bethkit-2.2.0-py3-none-win_amd64.whl |
|---|---|
| Size | 2.9 MB |
| Tags | Python 3 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
ca2c8a8c5440ca70c84d7f3bb299d2b450d35bdf7fa71565cf1080c8f4e94c3e
|
|
BLAKE2b-256 checksum How to use checksums |
376433cdc852f2d2fa80c402cff698ad0e516006428af0962c189d7d784c75fb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / bethkit-2.2.0-py3-none-manylinux_2_34_x86_64.whl
| Download URL | bethkit-2.2.0-py3-none-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 3.0 MB |
| Tags | Linux glibc 2.34+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
84af00a25d9526281a8e490100cd82e1ac4330fe372d7c6088c94b778193ad6f
|
|
BLAKE2b-256 checksum How to use checksums |
6307a1899a9ef0c665587eb603ba429488ec21e88d0715f384a055606e938205
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|