|
| 1 | +--- |
| 2 | +title: "Build an Extension Package" |
| 3 | +description: "Create an optional extension package for ZettelForge that provides an alternative backend (TypeDB), an integration (OpenCTI), or an operational feature (multi-tenant auth)." |
| 4 | +diataxis_type: "how-to" |
| 5 | +audience: "Python developers extending ZettelForge with optional packages" |
| 6 | +tags: [extensions, enterprise, packages, development, optional-features] |
| 7 | +last_updated: "2026-04-27" |
| 8 | +version: "2.6.0" |
| 9 | +--- |
| 10 | + |
| 11 | +# Build an Extension Package |
| 12 | + |
| 13 | +ZettelForge discovers installed extension packages at startup via `zettelforge.extensions.load_extensions()`. An extension is any Python package that registers itself under the `zettelforge.extensions` namespace or is importable as `zettelforge_enterprise`. |
| 14 | + |
| 15 | +## Prerequisites |
| 16 | + |
| 17 | +- ZettelForge installed (`pip install zettelforge`) |
| 18 | +- Python 3.12+ |
| 19 | +- For enterprise features: separate `zettelforge-enterprise` package (not distributed on PyPI) |
| 20 | + |
| 21 | +## How Extensions Are Loaded |
| 22 | + |
| 23 | +The extension loader in `zettelforge.extensions` follows a two-check discovery: |
| 24 | + |
| 25 | +1. **Try importing `zettelforge_enterprise`** -- if the package is installed, it is loaded as the `"enterprise"` extension. |
| 26 | +2. **Legacy env var fallback** -- if no package was found, check `THREATENGRAM_LICENSE_KEY`. If it matches the `TG-XXXX-XXXX-XXXX-XXXX` pattern, a marker is stored so `has_extension("enterprise")` returns `True`. |
| 27 | + |
| 28 | +```python |
| 29 | +from zettelforge.extensions import load_extensions, has_extension, get_extension |
| 30 | + |
| 31 | +load_extensions() |
| 32 | +print(has_extension("enterprise")) # True or False |
| 33 | +``` |
| 34 | + |
| 35 | +The loader is idempotent -- subsequent calls return the cached result without re-scanning the environment. |
| 36 | + |
| 37 | +## Steps |
| 38 | + |
| 39 | +### 1. Name your package |
| 40 | + |
| 41 | +Use the `zettelforge_` prefix to keep naming consistent and avoid collisions: |
| 42 | + |
| 43 | +- `zettelforge_enterprise` -- enterprise features (TypeDB, OpenCTI, telemetry) |
| 44 | +- `zettelforge_myfeature` -- your custom feature |
| 45 | + |
| 46 | +### 2. Create the package structure |
| 47 | + |
| 48 | +``` |
| 49 | +zettelforge-myfeature/ |
| 50 | + pyproject.toml |
| 51 | + src/ |
| 52 | + zettelforge_myfeature/ |
| 53 | + __init__.py |
| 54 | + feature.py |
| 55 | +``` |
| 56 | + |
| 57 | +The `__init__.py` can be empty -- the extension loader only needs the package to be importable. |
| 58 | + |
| 59 | +### 3. Register as a ZettelForge extension (optional) |
| 60 | + |
| 61 | +If you want your extension to be discoverable beyond the `zettelforge_enterprise` naming convention, register via a plugin entry point in `pyproject.toml`: |
| 62 | + |
| 63 | +```toml |
| 64 | +[project.entry-points."zettelforge.extensions"] |
| 65 | +myfeature = "zettelforge_myfeature" |
| 66 | +``` |
| 67 | + |
| 68 | +Then consumers can check for it by name: |
| 69 | + |
| 70 | +```python |
| 71 | +from zettelforge.extensions import has_extension |
| 72 | + |
| 73 | +if has_extension("myfeature"): |
| 74 | + # activate custom behaviour |
| 75 | +``` |
| 76 | + |
| 77 | +### 4. Respect the edition API |
| 78 | + |
| 79 | +Use the `zettelforge.edition` module to gate features behind the active edition: |
| 80 | + |
| 81 | +```python |
| 82 | +from zettelforge.edition import is_enterprise, EditionError |
| 83 | + |
| 84 | +if not is_enterprise(): |
| 85 | + raise EditionError("This feature requires ZettelForge Enterprise") |
| 86 | +``` |
| 87 | + |
| 88 | +Available edition functions: |
| 89 | + |
| 90 | +| Function | Returns | Description | |
| 91 | +|:---------|:--------|:------------| |
| 92 | +| `is_enterprise()` | `bool` | True if enterprise extensions are loaded | |
| 93 | +| `is_community()` | `bool` | True if no enterprise extensions | |
| 94 | +| `get_edition()` | `Edition` | `Edition.ENTERPRISE` or `Edition.COMMUNITY` | |
| 95 | +| `edition_name()` | `str` | `"ZettelForge + Extensions"` or `"ZettelForge"` | |
| 96 | + |
| 97 | +### 5. Expose extension features |
| 98 | + |
| 99 | +Your extension package should provide the actual feature implementations. The `get_extension()` function lets core code access your extension module: |
| 100 | + |
| 101 | +```python |
| 102 | +from zettelforge.extensions import get_extension |
| 103 | + |
| 104 | +enterprise = get_extension("enterprise") |
| 105 | +if enterprise is not None: |
| 106 | + # Access TypeDB backend, OpenCTI sync, telemetry, etc. |
| 107 | + enterprise.register_backends() |
| 108 | +``` |
| 109 | + |
| 110 | +### 6. Test your extension |
| 111 | + |
| 112 | +Use the `reset_extensions()` function in setup/teardown to clear cached state between tests: |
| 113 | + |
| 114 | +```python |
| 115 | +import os |
| 116 | +from unittest.mock import patch |
| 117 | +from zettelforge.extensions import load_extensions, has_extension, reset_extensions |
| 118 | + |
| 119 | +def test_extension_loaded(): |
| 120 | + reset_extensions() |
| 121 | + # Simulate having the enterprise package |
| 122 | + with patch.dict("sys.modules", {"zettelforge_enterprise": __import__("types")}): |
| 123 | + load_extensions() |
| 124 | + assert has_extension("enterprise") is True |
| 125 | + |
| 126 | + |
| 127 | +def test_extension_not_loaded(): |
| 128 | + reset_extensions() |
| 129 | + # Simulate missing package |
| 130 | + with patch.dict("sys.modules", {"zettelforge_enterprise": None}): |
| 131 | + load_extensions() |
| 132 | + assert has_extension("enterprise") is False |
| 133 | + |
| 134 | + |
| 135 | +def test_legacy_env_var_activates(): |
| 136 | + reset_extensions() |
| 137 | + os.environ["THREATENGRAM_LICENSE_KEY"] = "TG-1234-5678-9abc-def0" |
| 138 | + with patch.dict("sys.modules", {"zettelforge_enterprise": None}): |
| 139 | + load_extensions() |
| 140 | + assert has_extension("enterprise") is True |
| 141 | + |
| 142 | + |
| 143 | +def test_invalid_env_var_does_not_activate(): |
| 144 | + reset_extensions() |
| 145 | + os.environ["THREATENGRAM_LICENSE_KEY"] = "invalid-key" |
| 146 | + with patch.dict("sys.modules", {"zettelforge_enterprise": None}): |
| 147 | + load_extensions() |
| 148 | + assert has_extension("enterprise") is False |
| 149 | + |
| 150 | + |
| 151 | +def test_get_missing_returns_none(): |
| 152 | + reset_extensions() |
| 153 | + with patch.dict("sys.modules", {"zettelforge_enterprise": None}): |
| 154 | + assert get_extension("enterprise") is None |
| 155 | +``` |
| 156 | + |
| 157 | +### 7. Use the optional-feature pattern for SDK dependencies |
| 158 | + |
| 159 | +If your extension depends on an optional SDK (e.g., `typedb-client`, `pycti`), follow the optional-feature pattern: |
| 160 | + |
| 161 | +```python |
| 162 | +class MyFeature: |
| 163 | + def __init__(self): |
| 164 | + self._sdk = None |
| 165 | + self._lock = threading.Lock() |
| 166 | + |
| 167 | + def _ensure_loaded(self): |
| 168 | + if self._sdk is not None: |
| 169 | + return |
| 170 | + with self._lock: |
| 171 | + if self._sdk is not None: |
| 172 | + return |
| 173 | + try: |
| 174 | + import typedb # lazy import |
| 175 | + except ImportError as exc: |
| 176 | + raise ImportError( |
| 177 | + "TypeDB feature requires typedb-client. " |
| 178 | + "Install with: pip install zettelforge-enterprise" |
| 179 | + ) from exc |
| 180 | + self._sdk = typedb |
| 181 | +``` |
| 182 | + |
| 183 | +This ensures core ZettelForge never depends on your SDK, and the error surfaces only at the point of use. |
| 184 | + |
| 185 | +## LLM Quick Reference |
| 186 | + |
| 187 | +**Task**: Create a ZettelForge extension package. |
| 188 | + |
| 189 | +**Key functions**: `load_extensions()` (idempotent discovery), `has_extension(name)` (boolean check), `get_extension(name)` (module or None), `reset_extensions()` (test cleanup). |
| 190 | + |
| 191 | +**Edition module**: `is_enterprise()`, `is_community()`, `get_edition()`, `edition_name()` let core code gate features behind edition. |
| 192 | + |
| 193 | +**Activation paths**: Package import (`zettelforge_enterprise`) takes priority. Legacy env var (`THREATENGRAM_LICENSE_KEY=TG-XXXX-XXXX-XXXX-XXXX`) is the fallback for backward compatibility. |
| 194 | + |
| 195 | +**Test pattern**: `reset_extensions()` in setup, `patch.dict("sys.modules", ...)` to control whether the package exists, `patch.dict(os.environ, ...)` for env var tests. |
| 196 | + |
| 197 | +**Optional SDK pattern**: Lazy-import the SDK in a private `_ensure_loaded()` method. Never import at module level. Surface a clear `ImportError` with install instructions. |
| 198 | + |
| 199 | +**Entry point registration**: Add `[project.entry-points."zettelforge.extensions"]` in pyproject.toml for discovery by name beyond the `zettelforge_enterprise` convention. |
0 commit comments