shieldy-sdk 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. shieldy_sdk-1.0.2/.github/workflows/release.yml +66 -0
  2. shieldy_sdk-1.0.2/.gitignore +17 -0
  3. shieldy_sdk-1.0.2/CLAUDE.md +123 -0
  4. shieldy_sdk-1.0.2/PKG-INFO +208 -0
  5. shieldy_sdk-1.0.2/README.md +195 -0
  6. shieldy_sdk-1.0.2/docs/architecture.md +163 -0
  7. shieldy_sdk-1.0.2/examples/basic.py +76 -0
  8. shieldy_sdk-1.0.2/examples/protected_strings_marker.py +55 -0
  9. shieldy_sdk-1.0.2/native/native.lock.json +25 -0
  10. shieldy_sdk-1.0.2/pyproject.toml +73 -0
  11. shieldy_sdk-1.0.2/scripts/build_wheels.py +101 -0
  12. shieldy_sdk-1.0.2/scripts/fetch_native.py +123 -0
  13. shieldy_sdk-1.0.2/src/shieldy/__init__.py +88 -0
  14. shieldy_sdk-1.0.2/src/shieldy/_abi.py +120 -0
  15. shieldy_sdk-1.0.2/src/shieldy/_crypto.py +142 -0
  16. shieldy_sdk-1.0.2/src/shieldy/_native.py +128 -0
  17. shieldy_sdk-1.0.2/src/shieldy/_session.py +491 -0
  18. shieldy_sdk-1.0.2/src/shieldy/errors.py +83 -0
  19. shieldy_sdk-1.0.2/src/shieldy/obf.py +195 -0
  20. shieldy_sdk-1.0.2/src/shieldy/py.typed +0 -0
  21. shieldy_sdk-1.0.2/src/shieldy/values.py +176 -0
  22. shieldy_sdk-1.0.2/tests/conftest.py +54 -0
  23. shieldy_sdk-1.0.2/tests/e2e/README.md +66 -0
  24. shieldy_sdk-1.0.2/tests/e2e/conftest.py +182 -0
  25. shieldy_sdk-1.0.2/tests/e2e/dashboard.py +176 -0
  26. shieldy_sdk-1.0.2/tests/e2e/probe_app.py +110 -0
  27. shieldy_sdk-1.0.2/tests/e2e/test_e2e.py +201 -0
  28. shieldy_sdk-1.0.2/tests/e2e/test_offline.py +133 -0
  29. shieldy_sdk-1.0.2/tests/mock_runtime.py +256 -0
  30. shieldy_sdk-1.0.2/tests/test_abi.py +87 -0
  31. shieldy_sdk-1.0.2/tests/test_native_loading.py +141 -0
  32. shieldy_sdk-1.0.2/tests/test_no_typographic_dashes.py +29 -0
  33. shieldy_sdk-1.0.2/tests/test_obf.py +83 -0
  34. shieldy_sdk-1.0.2/tests/test_session.py +304 -0
  35. shieldy_sdk-1.0.2/tests/test_values.py +196 -0
  36. shieldy_sdk-1.0.2/third_party/shieldy_api.h +170 -0
  37. shieldy_sdk-1.0.2/uv.lock +1005 -0
@@ -0,0 +1,66 @@
1
+ name: release
2
+
3
+ # Publishes shieldy-sdk with PyPI trusted publishing (OIDC), no stored token.
4
+ # - a version tag (v1.2.3) publishes to PyPI (prod);
5
+ # - a manual run (Actions -> release -> Run workflow) publishes to PyPI or TestPyPI (test).
6
+ # Register this repo + release.yml as a trusted publisher on BOTH indexes: pypi.org (GitHub
7
+ # environment "pypi") and test.pypi.org (environment "testpypi"). The native runtime is pulled from
8
+ # the Bunny pull zone, which has token auth on, so both BUNNY_PULL_ZONE_HOST and
9
+ # BUNNY_PULL_ZONE_TOKEN_KEY are needed.
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+ workflow_dispatch:
14
+ inputs:
15
+ target:
16
+ description: "Index to publish to"
17
+ type: choice
18
+ options: [prod, test]
19
+ default: prod
20
+
21
+ jobs:
22
+ publish:
23
+ runs-on: ubuntu-latest
24
+ # A tag push is always prod; a manual run uses the chosen target. The environment name must
25
+ # match the trusted publisher configured on each index.
26
+ environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.target == 'test') && 'testpypi' || 'pypi' }}
27
+ permissions:
28
+ id-token: write # trusted publishing (OIDC)
29
+ contents: read # checkout of this private repo
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: astral-sh/setup-uv@v5
33
+ - name: Resolve the publish target
34
+ id: target
35
+ run: |
36
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.target }}" = "test" ]; then
37
+ echo "value=test" >> "$GITHUB_OUTPUT"
38
+ else
39
+ echo "value=prod" >> "$GITHUB_OUTPUT"
40
+ fi
41
+ # A tag release must match the package version; a manual (possibly test) run need not.
42
+ - name: The tag must match pyproject.toml
43
+ if: github.event_name == 'push'
44
+ run: |
45
+ version="$(python3 -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
46
+ test "v$version" = "${GITHUB_REF_NAME}" || { echo "tag ${GITHUB_REF_NAME} does not match version $version"; exit 1; }
47
+ # Pull the pinned hardened runtime (native/native.lock.json) from the Bunny pull zone (signed:
48
+ # the zone has token auth on) and build one platform wheel per blob (py3-none-<platform>),
49
+ # plus a pure sdist as the fallback for platforms with no bundled runtime.
50
+ - name: Fetch the pinned native runtime
51
+ env:
52
+ BUNNY_PULL_ZONE_HOST: ${{ secrets.BUNNY_PULL_ZONE_HOST }}
53
+ BUNNY_PULL_ZONE_TOKEN_KEY: ${{ secrets.BUNNY_PULL_ZONE_TOKEN_KEY }}
54
+ run: uv run python scripts/fetch_native.py
55
+ - name: Build platform wheels
56
+ run: uv run --with wheel python scripts/build_wheels.py
57
+ - name: Build the source distribution
58
+ run: uv build --sdist
59
+ - name: Publish to PyPI
60
+ if: steps.target.outputs.value == 'prod'
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+ - name: Publish to TestPyPI
63
+ if: steps.target.outputs.value == 'test'
64
+ uses: pypa/gh-action-pypi-publish@release/v1
65
+ with:
66
+ repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ /.venv/
4
+ /build/
5
+ /dist/
6
+ *.egg-info/
7
+ /.pytest_cache/
8
+ /.mypy_cache/
9
+ /.ruff_cache/
10
+ /.idea/
11
+ # Runtimes are build artifacts of ShieldyCore, never committed here.
12
+ *.dll
13
+ *.so
14
+ # Local settings for the end-to-end tests (dashboard credentials).
15
+ /tests/e2e/.env
16
+ # A DEV build of the runtime writes debug logs to ./logs of the working directory.
17
+ logs/
@@ -0,0 +1,123 @@
1
+ # CLAUDE.md
2
+
3
+ Orientation for Claude Code (and anyone new) in this repository. The full design rationale is in
4
+ `docs/architecture.md`; the customer-facing guide is `README.md`. Read the relevant section there
5
+ before a structural change.
6
+
7
+ ## What this is
8
+
9
+ `shieldy-sdk` (import `shieldy`): the Python client of Shieldy's native runtime (ShieldyCore's
10
+ `native.dll` / `native.so`), through `ctypes`. Python 3.9+, one dependency (`cryptography`).
11
+ Module-level functions operate on the one session a process has: load the runtime, sign in with a
12
+ license key, read license, variables, files and Protected Strings, all decrypted with a per-login
13
+ session key.
14
+
15
+ Sibling repositories implementing the same contract (ABI, wire crypto, error numbers, variable
16
+ grammar): `../../native/ShieldyCore` (runtime, `cpp-module/shieldy_api.h`),
17
+ `../examples/CppCmakeShieldy` (C++ SDK), `../ShieldyDotnetSdk` (.NET SDK),
18
+ `../../services/ShieldyBackend` (server; the `python` snippet in
19
+ `core-dashboard-api/services/string_obfuscation_service.py` must stay `shieldy.protected_string`).
20
+
21
+ ## Map
22
+
23
+ ```
24
+ src/shieldy/
25
+ __init__.py the public API, re-exported (names without underscore are public)
26
+ _session.py process-wide state, one RLock, every public call (start here)
27
+ _abi.py ctypes mirror of shieldy_api.h; no logic
28
+ _native.py path resolution (application dir, never cwd), loading, SC_GetApi
29
+ _crypto.py X25519 + SHA-256, HMAC attestation, ChaCha20-Poly1305 unwrap
30
+ errors.py Error IntEnum (numbers shared with C++/.NET), ShieldyError
31
+ values.py Config, License, SecureBytes, Feature
32
+ obf.py SHIELDY_OBF marker + `python -m shieldy.obf check`
33
+ tests/
34
+ mock_runtime.py SC_ApiV1 of real CFUNCTYPE callbacks, scenarios of the C++ mock_native.cpp
35
+ test_*.py unit tests (no server, no DLL)
36
+ e2e/ dashboard.py (stdlib REST client), probe_app.py (one process per scenario),
37
+ conftest.py (.env), test_e2e.py; marker `e2e`, excluded by default
38
+ examples/ basic.py, protected_strings_marker.py
39
+ third_party/shieldy_api.h copy of ShieldyCore's header; test_abi.py compares it
40
+ ```
41
+
42
+ ## Commands
43
+
44
+ ```bash
45
+ uv sync # creates .venv with pytest, ruff, mypy
46
+ uv run pytest # unit tests, ~2 s (addopts exclude e2e)
47
+ uv run pytest -m e2e # real runtime + local server, see below
48
+ uv run ruff check src tests examples # line length 120
49
+ uv run mypy # strict, src only
50
+ uv run --python 3.9 pytest # the floor
51
+ uv run python scripts/test_matrix.py # 3.9 to 3.14 plus the oldest cryptography, in throwaway envs
52
+ ```
53
+
54
+ ## E2E stack (local)
55
+
56
+ - Postgres + Redis containers (`shieldy-dev-postgres-1`, `shieldy-dev-redis-1`) from ShieldyDeploy.
57
+ - `ShieldyBackend/core-auth-api`: `uv run --package core-auth-api python main.py` (127.0.0.1:8010).
58
+ - `ShieldyBackend/core-dashboard-api`: same with `core-dashboard-api` (127.0.0.1:8020) and
59
+ `STRING_OBF_NATIVE_LIBRARY_PATH=../../native/ShieldyCore/cmake-builds/win-dev-x64/obfuscation-module/security.dll`.
60
+ Restart it after changing the backend: it does not reload.
61
+ - Runtime: `tests/e2e/runtime/native.dll` (gitignored), a DEV ShieldyCore build with the endpoint
62
+ edited to `http://127.0.0.1:8010` (how to build one: `tests/e2e/README.md`). Settings in
63
+ `tests/e2e/.env` (gitignored).
64
+ - Each test app gets `require_registered_native=false`, or `/initialize` refuses a local build.
65
+ - **Offline start tests (`test_offline.py`)** skip unless both deployment switches are on. A hand-started
66
+ service reads `ShieldyBackend/config/<env>/<service>.toml`, NOT `ShieldyDeploy/config/dev/` (that one is
67
+ for dev compose) and the backend copy has no `[offline_lease]`/`[offline_start]` tables, so start with
68
+ env vars (they beat the TOML): `OFFLINE_LEASE_ENABLED=true` for auth-api (the signing key
69
+ `OFFLINE_LEASE_SIGNING_KEY` is already in `ShieldyDeploy/env/dev/auth-api.env`) and
70
+ `OFFLINE_START_ENABLED=true` for dashboard-api. The skip message names the missing one.
71
+ The dev account's plan must include offline start (dev@shieldy.local does), or the suite skips on 402.
72
+ - Offline is simulated with `SHIELDY_DEV_FORCE_OFFLINE=1` (honored by DEV/TEST runtimes only):
73
+ `run_probe(..., offline=True)`. Leases go to a per-test `lease_directory=str(tmp_path)`, never the
74
+ machine's `%LOCALAPPDATA%\Shieldy\leases`. The runtime must be built from ShieldyCore `dev` with
75
+ offline start (Oct 2026+) or every offline test fails at `connection_state`.
76
+ - **Restarting the services on Windows:** auth-api (Sanic) spawns `multiprocessing` workers that survive
77
+ `Stop-Process` on `main.py` and keep port 8010 bound. Kill the children too: find them with
78
+ `Get-CimInstance Win32_Process -Filter "ParentProcessId=<pid>"` (command line
79
+ `spawn_main(parent_pid=...)`), or check `Get-NetTCPConnection -State Listen -LocalPort 8010,8020`
80
+ until both ports are free. Start from the service dir with the venv python:
81
+ `UV_NO_SYNC=1 ../.venv/Scripts/python.exe main.py` (`UV_NO_SYNC` keeps uv from re-syncing the
82
+ locally installed protocol packages back to the GitHub pin).
83
+
84
+ ## Invariants (do not break)
85
+
86
+ - `Error` numbers equal the C++ SDK's `shieldy::Error` and the .NET SDK's `ShieldyError`.
87
+ - `SC_ApiV1._fields_` order = `shieldy_api.h`; append only. `test_abi.py` parses the header.
88
+ - Wire crypto: session key = SHA-256(X25519); attestation = HMAC-SHA256(key, 16-byte nonce); values
89
+ = `nonce(12) || tag(16) || ciphertext`, ChaCha20-Poly1305, no AAD (the tag goes first, so
90
+ `_crypto.Session.decrypt` reorders it for `cryptography`).
91
+ - `appMode = 3` (Python) in `SC_Config`.
92
+ - A relative runtime path resolves against the application directory (frozen exe dir, then
93
+ `sys._MEIPASS`, else the `__main__` script dir), never the working directory.
94
+ - Every runtime-allocated `SC_Buffer` goes back through `free_buffer` (tests assert none left).
95
+ - Variable grammar = C++ `from_chars` (no `+`, no `_` separators, no hex).
96
+ - Python 3.9 floor: `typing.Optional`/`List`, no `match`, no `X | Y` at runtime (ruff ignores UP006/7/35/45).
97
+ - Never write a typographic dash (U+2013/2014/2015); `test_no_typographic_dashes.py` scans the repo.
98
+ - English, US spelling, in code, docs and messages.
99
+
100
+ ## Runtime versions
101
+
102
+ ShieldyCore since October 2026 (multi-session, ShieldyJavaSdk Phase 5) gives every context its own
103
+ server session: `login()` after a refused key and `init()` after `shutdown()` work, and the e2e
104
+ suite tests both. Older runtimes allowed one server session per process; `login()`, the README and
105
+ the architecture doc say what that means for integrators who still ship one.
106
+
107
+ ## Release process
108
+
109
+ Releasing this SDK (pinning the runtime in `native/native.lock.json`, `fetch_native.py`,
110
+ `build_wheels.py`, `.github/workflows/release.yml`) is phases B and C of the process described in
111
+ `../../release/ShieldyRelease/steps.toml` (panel: `python -m shieldy_release.cli
112
+ serve`). A change to any of these, to where the package version lives, or to the publish targets,
113
+ secrets or tag rules must be checked against `steps.toml`, its checks and its runbooks in the same
114
+ piece of work. See ShieldyRelease's CLAUDE.md, "Keeping the process in sync".
115
+
116
+ ## Gotchas
117
+
118
+ - ctypes callbacks in the mock must stay referenced (`MockRuntime._keep`), or the trampoline is
119
+ freed under the SDK.
120
+ - A DEV runtime writes `./logs` into the working directory (`logs/` is gitignored).
121
+ - The e2e `dashboard.py` carries the `__Host-` session cookie by hand: `http.cookiejar` will not
122
+ send a `Secure` cookie back over plain-http localhost.
123
+ - `cryptography` before 45 has no `decrypt_into`; `_crypto` falls back to one immutable copy.
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.5
2
+ Name: shieldy-sdk
3
+ Version: 1.0.2
4
+ Summary: Python client for Shieldy's native runtime: licensing, remote variables, files and Protected Strings.
5
+ Project-URL: Documentation, https://docs.shieldy.app
6
+ License: Proprietary
7
+ Classifier: Operating System :: Microsoft :: Windows
8
+ Classifier: Operating System :: POSIX :: Linux
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: cryptography>=42
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Shieldy Python SDK
15
+
16
+ The Python client for Shieldy's native runtime (`native.dll` on Windows, `native.so` on Linux).
17
+ It signs your application in with the end user's license key and reads what the dashboard
18
+ delivers to that license: the license itself, remote variables, files and Protected Strings.
19
+
20
+ ```python
21
+ import shieldy
22
+
23
+ try:
24
+ shieldy.start(shieldy.Config("<App GUID>", "1.0"), read_license_key_from_user())
25
+ except shieldy.ShieldyError as exc:
26
+ # exc.error is a shieldy.Error, e.g. Error.AUTHENTICATION_FAILED
27
+ raise SystemExit(f"Could not sign in: {exc.error}")
28
+
29
+ lic = shieldy.license()
30
+ if lic.level >= 20:
31
+ enable_pro_features()
32
+
33
+ api_url = shieldy.variable_by_id("<Lookup ID>") # str
34
+ max_users = shieldy.variable_by_id_as("<Lookup ID>", int) # int
35
+ with shieldy.file_by_id("<Lookup ID>") as pak: # SecureBytes, wiped after the block
36
+ load_assets(pak.view())
37
+ token = shieldy.protected_string("<ciphertext>") # str
38
+ ```
39
+
40
+ Python 3.9 or newer, 64-bit or 32-bit to match the runtime you ship. The only dependency is
41
+ [`cryptography`](https://cryptography.io).
42
+
43
+ ## Installing
44
+
45
+ ```powershell
46
+ pip install shieldy-sdk # once published
47
+ pip install path/to/ShieldyPythonSdk # from a checkout
48
+ ```
49
+
50
+ Ship the runtime with your application. Download it from the dashboard (your app -> Activity ->
51
+ Runtime -> "Download the SDK"), and put it next to your entry script or your frozen executable.
52
+
53
+ ## Where the runtime is loaded from
54
+
55
+ The SDK loads exactly one file, chosen in this order:
56
+
57
+ 1. `Config.native_library`, if you set it;
58
+ 2. the `SHIELDY_NATIVE_LIBRARY` environment variable;
59
+ 3. `native.dll` (`native.so` on Linux).
60
+
61
+ A relative path is resolved against your application's directory, **never** the current working
62
+ directory or `PATH`, so a library planted where the user starts your program is not picked up.
63
+ Your application's directory is:
64
+
65
+ | How the app runs | Directory |
66
+ |---|---|
67
+ | `python app.py` | the directory of `app.py` |
68
+ | `python -m package` | the directory of `package/__main__.py` |
69
+ | PyInstaller, cx_Freeze, py2exe | the directory of the executable, then PyInstaller's bundle directory (`sys._MEIPASS`) |
70
+ | interactive interpreter, `python -c` | none: give an absolute path |
71
+
72
+ `shieldy.application_directory()` shows what the SDK settled on.
73
+
74
+ With PyInstaller, either copy the runtime next to the built executable, or bundle it:
75
+ `pyinstaller --add-binary "native.dll;." app.py` (`native.so:.` on Linux).
76
+
77
+ ## API
78
+
79
+ All calls are module-level functions on the one session the process has. They are thread-safe.
80
+
81
+ | Call | Returns |
82
+ |---|---|
83
+ | `init(config)` | Loads the runtime and runs its protected start-up (`/initialize` on the server). |
84
+ | `login(key)` | Signs in with the license key. |
85
+ | `start(config, key)` | `init` then `login`. |
86
+ | `shutdown()` | Ends the session. Also runs automatically when the interpreter exits. |
87
+ | `is_logged_in()` | `bool` |
88
+ | `supports(Feature.LOOKUP_IDS)` | Whether the runtime has the `*_by_id` calls (protocol 1.6.0+). |
89
+ | `supports(Feature.OFFLINE_START)` | Whether the runtime has `connection_state()` (protocol 1.12.0+). |
90
+ | `connection_state()` | `ConnectionState`: `offline` (`True` when this run started without a connection), `offline_until` (until when this device can start offline; `None`: it cannot). |
91
+ | `license()` | `License`: `level`, `hwid_seats_used`, `hwid_seats_total`, `created`, `expires` (`None`: never), `is_expired()`, `days_left()`. Read once at login. |
92
+ | `variable_by_id(id)` / `variable(name)` | `str` |
93
+ | `variable_by_id_as(id, type)` / `variable_as(name, type)` | `int`, `float`, `bool` or `str` |
94
+ | `file_by_id(id)` / `file(name)` | `SecureBytes`, wiped from memory when done |
95
+ | `protected_string(ciphertext)` | `str`, cached for the rest of the session |
96
+
97
+ Prefer the `*_by_id` calls: a lookup id is random, a name ends up readable in your code. A
98
+ variable whose name is hidden from the client (dashboard: "Hide name from client") can only be
99
+ read by id.
100
+
101
+ `bool` variables accept `true`, `false`, `1`, `0`. Numbers follow the C++ SDK's rules: surrounding
102
+ whitespace is ignored, a leading `+`, digit separators (`1_000`) and hex are not accepted.
103
+
104
+ ### Errors
105
+
106
+ Every call raises `shieldy.ShieldyError` when it cannot deliver; `exc.error` says why. The codes
107
+ have the same numbers as the C++ SDK's `shieldy::Error`.
108
+
109
+ | `Error` | Meaning |
110
+ |---|---|
111
+ | `NATIVE_LIBRARY_NOT_FOUND` | No runtime at the resolved path, or one for the wrong architecture. |
112
+ | `NATIVE_LIBRARY_INVALID` | The file loaded but is not a Shieldy runtime. |
113
+ | `INITIALIZATION_FAILED` | The server refused to start the app: unknown App GUID, suspended app, outdated version, unregistered runtime build, maintenance. |
114
+ | `AUTHENTICATION_FAILED` | The key was refused: wrong, expired, banned, or no free device seat. The server does not say which. |
115
+ | `NOT_INITIALIZED`, `NOT_LOGGED_IN` | A call came too early. |
116
+ | `NOT_FOUND` | No variable with that id or name for this license. |
117
+ | `NATIVE_TOO_OLD` | The runtime predates the call; see `supports()`. |
118
+ | `ATTESTATION_FAILED`, `KEY_EXCHANGE_FAILED`, `DECRYPT_FAILED` | The runtime could not prove it holds the session key, or a value did not decrypt. Treat as tampering. |
119
+ | `INVALID_VALUE` | A variable did not parse as the requested type. |
120
+ | `SERVER_UNREACHABLE` | No connection, and this device cannot start offline (offline start is off, it never signed in online here, or that was with another key). |
121
+ | `OFFLINE_EXPIRED` | No connection, and this device's offline time (or the license) has run out. |
122
+ | `CLOCK_INVALID` | No connection, and the system clock reads earlier than the last run. |
123
+ | `OFFLINE_UNAVAILABLE` | The call needs a connection and the app is running offline (files). |
124
+
125
+ The full list is in `src/shieldy/errors.py`.
126
+
127
+ ### Offline start
128
+
129
+ When the app has offline start on (dashboard: Security -> Availability), a device that signed in
130
+ online can start without internet for the number of days the app allows. Nothing changes in your
131
+ code: `init()` and `login()` succeed offline as they do online. Show it if you like:
132
+
133
+ ```python
134
+ state = shieldy.connection_state()
135
+ if state.offline:
136
+ show_banner(f"Offline - works until {state.offline_until:%b %d}")
137
+ ```
138
+
139
+ Offline, `license()`, variables and Protected Strings work; files need a connection. The device
140
+ has to sign in with the same key it last used online. For a portable install, keep the lease next
141
+ to the app with `Config(..., lease_directory="leases")`. To test it without pulling the cable, set
142
+ `SHIELDY_DEV_FORCE_OFFLINE=1` with a development build of the runtime.
143
+
144
+ ### Trying another key
145
+
146
+ When a key is refused, `login()` can be called again with another one, and `init()` works again
147
+ after `shutdown()`. Runtimes built before October 2026 allowed one sign-in per process (the server
148
+ ends the session at a refused key and they could not open another); with one of those, restart the
149
+ process to let the user try another key.
150
+
151
+ ### Files and memory
152
+
153
+ `file_by_id()` returns a `SecureBytes`: a buffer the SDK zeroes when you call `wipe()`, when a
154
+ `with` block around it ends, or when it is garbage collected. Read it through the object
155
+ (`len()`, indexing, slicing, `view()`, `text()`); `bytes(data)` makes a copy that can never be
156
+ wiped. This is best effort, as it has to be in Python: copies the interpreter makes on its own
157
+ (a `str` from `text()`, for instance) are out of the SDK's reach.
158
+
159
+ ## Protected Strings in the build
160
+
161
+ Write secrets as `SHIELDY_OBF("...")` (double quotes) and keep coding against the plain text:
162
+
163
+ ```python
164
+ import shieldy
165
+ from shieldy.obf import SHIELDY_OBF
166
+
167
+ def api_endpoint() -> str:
168
+ return SHIELDY_OBF("https://api.example.com/v2")
169
+ ```
170
+
171
+ Before a release, run the dashboard's CLI over each file. It rewrites every marker to
172
+ `shieldy.protected_string("<ciphertext>")`:
173
+
174
+ ```powershell
175
+ python tools/shieldy_strings_cli.py --api-url https://<dashboard>/api --app-guid <App GUID> `
176
+ --api-key <key with apps:write> --file src/endpoints.py --language python --in-place
177
+ python -m shieldy.obf check src/ # exit code 1 while any marker is left
178
+ ```
179
+
180
+ Python has no compile step that could refuse a forgotten marker, so `SHIELDY_OBF` raises
181
+ `UnencryptedMarkerError` when it runs, unless `SHIELDY_OBF_PLAINTEXT=1` is set (do that while you
182
+ develop, never in a release), and `python -m shieldy.obf check` belongs in your release pipeline.
183
+ Read protected strings where they are used, after `login()`, never at module level.
184
+ See `examples/protected_strings_marker.py`.
185
+
186
+ ## Examples
187
+
188
+ - `examples/basic.py`: the whole flow, configured from environment variables
189
+ (`SHIELDY_APP_GUID`, `SHIELDY_LICENSE_KEY`, optional `SHIELDY_VARIABLE_ID`, `SHIELDY_FILE_ID`,
190
+ `SHIELDY_PROTECTED_STRING`).
191
+ - `examples/protected_strings_marker.py`: the `SHIELDY_OBF` workflow.
192
+
193
+ ## Working on this repository
194
+
195
+ ```powershell
196
+ uv sync # Python 3.9+, creates .venv with the dev tools
197
+ uv run pytest # unit tests against a mock runtime, no server needed
198
+ uv run pytest -m e2e # a real runtime against a real server, see tests/e2e/README.md
199
+ uv run ruff check src tests examples
200
+ uv run mypy # strict
201
+ ```
202
+
203
+ How the SDK works inside, and why it is built the way it is: [docs/architecture.md](docs/architecture.md).
204
+ A quick map of the repository, its commands and invariants: [CLAUDE.md](CLAUDE.md).
205
+
206
+ `third_party/shieldy_api.h` is a copy of ShieldyCore's `cpp-module/shieldy_api.h`, the ABI the
207
+ SDK mirrors in `src/shieldy/_abi.py`. `tests/test_abi.py` fails when the two disagree, and when the
208
+ copy differs from a ShieldyCore checkout next to this repository.
@@ -0,0 +1,195 @@
1
+ # Shieldy Python SDK
2
+
3
+ The Python client for Shieldy's native runtime (`native.dll` on Windows, `native.so` on Linux).
4
+ It signs your application in with the end user's license key and reads what the dashboard
5
+ delivers to that license: the license itself, remote variables, files and Protected Strings.
6
+
7
+ ```python
8
+ import shieldy
9
+
10
+ try:
11
+ shieldy.start(shieldy.Config("<App GUID>", "1.0"), read_license_key_from_user())
12
+ except shieldy.ShieldyError as exc:
13
+ # exc.error is a shieldy.Error, e.g. Error.AUTHENTICATION_FAILED
14
+ raise SystemExit(f"Could not sign in: {exc.error}")
15
+
16
+ lic = shieldy.license()
17
+ if lic.level >= 20:
18
+ enable_pro_features()
19
+
20
+ api_url = shieldy.variable_by_id("<Lookup ID>") # str
21
+ max_users = shieldy.variable_by_id_as("<Lookup ID>", int) # int
22
+ with shieldy.file_by_id("<Lookup ID>") as pak: # SecureBytes, wiped after the block
23
+ load_assets(pak.view())
24
+ token = shieldy.protected_string("<ciphertext>") # str
25
+ ```
26
+
27
+ Python 3.9 or newer, 64-bit or 32-bit to match the runtime you ship. The only dependency is
28
+ [`cryptography`](https://cryptography.io).
29
+
30
+ ## Installing
31
+
32
+ ```powershell
33
+ pip install shieldy-sdk # once published
34
+ pip install path/to/ShieldyPythonSdk # from a checkout
35
+ ```
36
+
37
+ Ship the runtime with your application. Download it from the dashboard (your app -> Activity ->
38
+ Runtime -> "Download the SDK"), and put it next to your entry script or your frozen executable.
39
+
40
+ ## Where the runtime is loaded from
41
+
42
+ The SDK loads exactly one file, chosen in this order:
43
+
44
+ 1. `Config.native_library`, if you set it;
45
+ 2. the `SHIELDY_NATIVE_LIBRARY` environment variable;
46
+ 3. `native.dll` (`native.so` on Linux).
47
+
48
+ A relative path is resolved against your application's directory, **never** the current working
49
+ directory or `PATH`, so a library planted where the user starts your program is not picked up.
50
+ Your application's directory is:
51
+
52
+ | How the app runs | Directory |
53
+ |---|---|
54
+ | `python app.py` | the directory of `app.py` |
55
+ | `python -m package` | the directory of `package/__main__.py` |
56
+ | PyInstaller, cx_Freeze, py2exe | the directory of the executable, then PyInstaller's bundle directory (`sys._MEIPASS`) |
57
+ | interactive interpreter, `python -c` | none: give an absolute path |
58
+
59
+ `shieldy.application_directory()` shows what the SDK settled on.
60
+
61
+ With PyInstaller, either copy the runtime next to the built executable, or bundle it:
62
+ `pyinstaller --add-binary "native.dll;." app.py` (`native.so:.` on Linux).
63
+
64
+ ## API
65
+
66
+ All calls are module-level functions on the one session the process has. They are thread-safe.
67
+
68
+ | Call | Returns |
69
+ |---|---|
70
+ | `init(config)` | Loads the runtime and runs its protected start-up (`/initialize` on the server). |
71
+ | `login(key)` | Signs in with the license key. |
72
+ | `start(config, key)` | `init` then `login`. |
73
+ | `shutdown()` | Ends the session. Also runs automatically when the interpreter exits. |
74
+ | `is_logged_in()` | `bool` |
75
+ | `supports(Feature.LOOKUP_IDS)` | Whether the runtime has the `*_by_id` calls (protocol 1.6.0+). |
76
+ | `supports(Feature.OFFLINE_START)` | Whether the runtime has `connection_state()` (protocol 1.12.0+). |
77
+ | `connection_state()` | `ConnectionState`: `offline` (`True` when this run started without a connection), `offline_until` (until when this device can start offline; `None`: it cannot). |
78
+ | `license()` | `License`: `level`, `hwid_seats_used`, `hwid_seats_total`, `created`, `expires` (`None`: never), `is_expired()`, `days_left()`. Read once at login. |
79
+ | `variable_by_id(id)` / `variable(name)` | `str` |
80
+ | `variable_by_id_as(id, type)` / `variable_as(name, type)` | `int`, `float`, `bool` or `str` |
81
+ | `file_by_id(id)` / `file(name)` | `SecureBytes`, wiped from memory when done |
82
+ | `protected_string(ciphertext)` | `str`, cached for the rest of the session |
83
+
84
+ Prefer the `*_by_id` calls: a lookup id is random, a name ends up readable in your code. A
85
+ variable whose name is hidden from the client (dashboard: "Hide name from client") can only be
86
+ read by id.
87
+
88
+ `bool` variables accept `true`, `false`, `1`, `0`. Numbers follow the C++ SDK's rules: surrounding
89
+ whitespace is ignored, a leading `+`, digit separators (`1_000`) and hex are not accepted.
90
+
91
+ ### Errors
92
+
93
+ Every call raises `shieldy.ShieldyError` when it cannot deliver; `exc.error` says why. The codes
94
+ have the same numbers as the C++ SDK's `shieldy::Error`.
95
+
96
+ | `Error` | Meaning |
97
+ |---|---|
98
+ | `NATIVE_LIBRARY_NOT_FOUND` | No runtime at the resolved path, or one for the wrong architecture. |
99
+ | `NATIVE_LIBRARY_INVALID` | The file loaded but is not a Shieldy runtime. |
100
+ | `INITIALIZATION_FAILED` | The server refused to start the app: unknown App GUID, suspended app, outdated version, unregistered runtime build, maintenance. |
101
+ | `AUTHENTICATION_FAILED` | The key was refused: wrong, expired, banned, or no free device seat. The server does not say which. |
102
+ | `NOT_INITIALIZED`, `NOT_LOGGED_IN` | A call came too early. |
103
+ | `NOT_FOUND` | No variable with that id or name for this license. |
104
+ | `NATIVE_TOO_OLD` | The runtime predates the call; see `supports()`. |
105
+ | `ATTESTATION_FAILED`, `KEY_EXCHANGE_FAILED`, `DECRYPT_FAILED` | The runtime could not prove it holds the session key, or a value did not decrypt. Treat as tampering. |
106
+ | `INVALID_VALUE` | A variable did not parse as the requested type. |
107
+ | `SERVER_UNREACHABLE` | No connection, and this device cannot start offline (offline start is off, it never signed in online here, or that was with another key). |
108
+ | `OFFLINE_EXPIRED` | No connection, and this device's offline time (or the license) has run out. |
109
+ | `CLOCK_INVALID` | No connection, and the system clock reads earlier than the last run. |
110
+ | `OFFLINE_UNAVAILABLE` | The call needs a connection and the app is running offline (files). |
111
+
112
+ The full list is in `src/shieldy/errors.py`.
113
+
114
+ ### Offline start
115
+
116
+ When the app has offline start on (dashboard: Security -> Availability), a device that signed in
117
+ online can start without internet for the number of days the app allows. Nothing changes in your
118
+ code: `init()` and `login()` succeed offline as they do online. Show it if you like:
119
+
120
+ ```python
121
+ state = shieldy.connection_state()
122
+ if state.offline:
123
+ show_banner(f"Offline - works until {state.offline_until:%b %d}")
124
+ ```
125
+
126
+ Offline, `license()`, variables and Protected Strings work; files need a connection. The device
127
+ has to sign in with the same key it last used online. For a portable install, keep the lease next
128
+ to the app with `Config(..., lease_directory="leases")`. To test it without pulling the cable, set
129
+ `SHIELDY_DEV_FORCE_OFFLINE=1` with a development build of the runtime.
130
+
131
+ ### Trying another key
132
+
133
+ When a key is refused, `login()` can be called again with another one, and `init()` works again
134
+ after `shutdown()`. Runtimes built before October 2026 allowed one sign-in per process (the server
135
+ ends the session at a refused key and they could not open another); with one of those, restart the
136
+ process to let the user try another key.
137
+
138
+ ### Files and memory
139
+
140
+ `file_by_id()` returns a `SecureBytes`: a buffer the SDK zeroes when you call `wipe()`, when a
141
+ `with` block around it ends, or when it is garbage collected. Read it through the object
142
+ (`len()`, indexing, slicing, `view()`, `text()`); `bytes(data)` makes a copy that can never be
143
+ wiped. This is best effort, as it has to be in Python: copies the interpreter makes on its own
144
+ (a `str` from `text()`, for instance) are out of the SDK's reach.
145
+
146
+ ## Protected Strings in the build
147
+
148
+ Write secrets as `SHIELDY_OBF("...")` (double quotes) and keep coding against the plain text:
149
+
150
+ ```python
151
+ import shieldy
152
+ from shieldy.obf import SHIELDY_OBF
153
+
154
+ def api_endpoint() -> str:
155
+ return SHIELDY_OBF("https://api.example.com/v2")
156
+ ```
157
+
158
+ Before a release, run the dashboard's CLI over each file. It rewrites every marker to
159
+ `shieldy.protected_string("<ciphertext>")`:
160
+
161
+ ```powershell
162
+ python tools/shieldy_strings_cli.py --api-url https://<dashboard>/api --app-guid <App GUID> `
163
+ --api-key <key with apps:write> --file src/endpoints.py --language python --in-place
164
+ python -m shieldy.obf check src/ # exit code 1 while any marker is left
165
+ ```
166
+
167
+ Python has no compile step that could refuse a forgotten marker, so `SHIELDY_OBF` raises
168
+ `UnencryptedMarkerError` when it runs, unless `SHIELDY_OBF_PLAINTEXT=1` is set (do that while you
169
+ develop, never in a release), and `python -m shieldy.obf check` belongs in your release pipeline.
170
+ Read protected strings where they are used, after `login()`, never at module level.
171
+ See `examples/protected_strings_marker.py`.
172
+
173
+ ## Examples
174
+
175
+ - `examples/basic.py`: the whole flow, configured from environment variables
176
+ (`SHIELDY_APP_GUID`, `SHIELDY_LICENSE_KEY`, optional `SHIELDY_VARIABLE_ID`, `SHIELDY_FILE_ID`,
177
+ `SHIELDY_PROTECTED_STRING`).
178
+ - `examples/protected_strings_marker.py`: the `SHIELDY_OBF` workflow.
179
+
180
+ ## Working on this repository
181
+
182
+ ```powershell
183
+ uv sync # Python 3.9+, creates .venv with the dev tools
184
+ uv run pytest # unit tests against a mock runtime, no server needed
185
+ uv run pytest -m e2e # a real runtime against a real server, see tests/e2e/README.md
186
+ uv run ruff check src tests examples
187
+ uv run mypy # strict
188
+ ```
189
+
190
+ How the SDK works inside, and why it is built the way it is: [docs/architecture.md](docs/architecture.md).
191
+ A quick map of the repository, its commands and invariants: [CLAUDE.md](CLAUDE.md).
192
+
193
+ `third_party/shieldy_api.h` is a copy of ShieldyCore's `cpp-module/shieldy_api.h`, the ABI the
194
+ SDK mirrors in `src/shieldy/_abi.py`. `tests/test_abi.py` fails when the two disagree, and when the
195
+ copy differs from a ShieldyCore checkout next to this repository.