Skip to content

Commit bdd72b0

Browse files
authored
Merge pull request #182 from LockInTime/feat/python-sdk-165
feat(sdk): add the typed Python client
2 parents 62421fb + 8026dfb commit bdd72b0

28 files changed

Lines changed: 7682 additions & 1 deletion

‎.github/workflows/ci.yml‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,45 @@ jobs:
8888
working-directory: packages/headless-npm
8989
run: npm pack --dry-run
9090

91+
python-sdk:
92+
name: Python SDK (${{ matrix.os }}, ${{ matrix.python }})
93+
runs-on: ${{ matrix.os }}
94+
timeout-minutes: 15
95+
strategy:
96+
fail-fast: false
97+
matrix:
98+
os: [ubuntu-latest, macos-latest]
99+
python: ["3.11", "3.12", "3.13", "3.14"]
100+
steps:
101+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
102+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
103+
with:
104+
python-version: ${{ matrix.python }}
105+
cache: pip
106+
cache-dependency-path: packages/headless-python/pyproject.toml
107+
- name: Install Python SDK development tools
108+
working-directory: packages/headless-python
109+
run: python -m pip install -e '.[dev]'
110+
- name: Verify generated protocol contract
111+
working-directory: packages/headless-python
112+
run: python scripts/generate.py --check
113+
- name: Format and lint
114+
working-directory: packages/headless-python
115+
run: |
116+
ruff check .
117+
ruff format --check .
118+
- name: Static typing
119+
working-directory: packages/headless-python
120+
run: mypy
121+
- name: Unit and lifecycle tests
122+
working-directory: packages/headless-python
123+
run: pytest
124+
- name: Build and verify exact package contents
125+
working-directory: packages/headless-python
126+
run: |
127+
python -m build
128+
python scripts/verify_package.py
129+
91130
protocol:
92131
name: Protocol suite
93132
runs-on: ubuntu-latest
@@ -149,6 +188,11 @@ jobs:
149188
with:
150189
node-version: 22
151190
cache: pnpm
191+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
192+
with:
193+
python-version: "3.14"
194+
cache: pip
195+
cache-dependency-path: packages/headless-python/pyproject.toml
152196
- run: pnpm install --frozen-lockfile --filter @lockintime/headless
153197
- name: Build app
154198
run: ./apps/headless/build.sh
@@ -161,6 +205,13 @@ jobs:
161205
HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless
162206
HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless
163207
run: node packages/headless-npm/test/macos-swift-integration.mjs
208+
- name: Install Python SDK
209+
run: python -m pip install ./packages/headless-python
210+
- name: Swift CLI to Python SDK integration
211+
env:
212+
HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless
213+
HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless
214+
run: python packages/headless-python/tests/swift_integration.py
164215

165216
macos-e2e:
166217
name: macOS E2E (WKWebView)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Publish Python SDK
2+
3+
on:
4+
push:
5+
tags:
6+
- "python-v*"
7+
pull_request:
8+
paths:
9+
- ".github/workflows/python-release.yml"
10+
- "packages/headless-python/**"
11+
- "sdk/protocol-fixtures.json"
12+
- "sdk/protocol-schema.json"
13+
workflow_dispatch:
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: python-release-${{ github.ref }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
build:
24+
name: Verify and build Python SDK
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 15
27+
steps:
28+
# v7.0.1 at this verified commit.
29+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
30+
with:
31+
fetch-depth: 0
32+
# v6.3.0 at this verified commit.
33+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
34+
with:
35+
python-version: "3.11"
36+
cache: pip
37+
cache-dependency-path: packages/headless-python/pyproject.toml
38+
- name: Verify tag matches package version
39+
if: startsWith(github.ref, 'refs/tags/python-v')
40+
working-directory: packages/headless-python
41+
env:
42+
RELEASE_TAG: ${{ github.ref_name }}
43+
run: |
44+
set -euo pipefail
45+
package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
46+
test "$RELEASE_TAG" = "python-v$package_version"
47+
git fetch --no-tags origin main:refs/remotes/origin/main
48+
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main || {
49+
echo "Python SDK releases must point to a commit already merged into main" >&2
50+
exit 64
51+
}
52+
- name: Install development tools
53+
working-directory: packages/headless-python
54+
run: python -m pip install -e '.[dev]'
55+
- name: Verify source and tests
56+
working-directory: packages/headless-python
57+
run: |
58+
python scripts/generate.py --check
59+
ruff check .
60+
ruff format --check .
61+
mypy
62+
pytest
63+
- name: Build exact distributions
64+
working-directory: packages/headless-python
65+
run: |
66+
python -m build
67+
python scripts/verify_package.py
68+
- name: Install and import built distributions
69+
working-directory: packages/headless-python
70+
run: |
71+
python -m venv "$RUNNER_TEMP/python-wheel"
72+
"$RUNNER_TEMP/python-wheel/bin/python" -m pip install --no-deps dist/*.whl
73+
"$RUNNER_TEMP/python-wheel/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)'
74+
python -m venv "$RUNNER_TEMP/python-sdist"
75+
"$RUNNER_TEMP/python-sdist/bin/python" -m pip install --no-deps dist/*.tar.gz
76+
"$RUNNER_TEMP/python-sdist/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)'
77+
# v7.0.1 at this verified commit.
78+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
79+
with:
80+
name: python-distributions
81+
path: packages/headless-python/dist/
82+
if-no-files-found: error
83+
retention-days: 7
84+
85+
publish:
86+
name: Publish to PyPI
87+
needs: build
88+
if: startsWith(github.ref, 'refs/tags/python-v')
89+
runs-on: ubuntu-latest
90+
environment:
91+
name: pypi
92+
url: https://pypi.org/project/lockintime-headless/
93+
permissions:
94+
id-token: write
95+
steps:
96+
# v8 at this verified commit; every action in the OIDC-enabled job is immutable.
97+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
98+
with:
99+
name: python-distributions
100+
path: dist
101+
# release/v1 at this verified commit; keep OIDC authority immutable.
102+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33

‎apps/headless/Dockerfile.linux‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ CMD ["/usr/local/bin/headless", "help"]
5656
FROM runtime-base AS test
5757
USER root
5858
RUN apt-get update \
59-
&& apt-get install -y --no-install-recommends busybox expect gnome-keyring \
59+
&& apt-get install -y --no-install-recommends busybox expect gnome-keyring python3 \
6060
&& rm -rf /var/lib/apt/lists/*
6161
RUN install -d -m 0700 -o headless -g headless /run/user/10001
6262
COPY Tests/Fixtures /opt/headless/fixtures

‎apps/headless/Tests/linux-docker.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ trap restore_evidence_owner EXIT INT TERM
3737
docker run --rm --name headless-p1-e2e --shm-size=1g --cap-add=SYS_ADMIN \
3838
-e HEADLESS_EVIDENCE_DIR=/evidence -v "$EVIDENCE_DIR:/evidence" \
3939
headless-p1-test /opt/headless/linux-e2e.sh
40+
docker run --rm --name headless-python-sdk-integration --shm-size=1g --cap-add=SYS_ADMIN \
41+
-e PYTHONPATH=/opt/python-sdk/src \
42+
-e HEADLESS_TEST_CLI=/usr/local/bin/headless \
43+
-e HEADLESS_TEST_HOST=/usr/local/bin/headless-host \
44+
-v "$PWD/../../packages/headless-python:/opt/python-sdk:ro" \
45+
headless-p1-test python3 /opt/python-sdk/tests/swift_integration.py
4046
restore_evidence_owner
4147
trap - EXIT INT TERM
4248
(
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__/
2+
*.py[cod]
3+
.mypy_cache/
4+
.pytest_cache/
5+
.ruff_cache/
6+
build/
7+
dist/
8+
*.egg-info/

‎packages/headless-python/LICENSE‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2026 LockInTime
4+
Copyright (c) 2026 Antiwork, Inc. (original chromeless foundation)
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.

‎packages/headless-python/README.md‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Headless Python SDK
2+
3+
`lockintime-headless` is the zero-runtime-dependency Python client for the local
4+
[Headless](https://github.com/LockInTime/headless) browser host. It supports CPython
5+
3.11 through 3.14 on macOS and Linux.
6+
7+
The SDK connects directly to Headless's private per-user Unix socket. It never opens
8+
a TCP port, evaluates arbitrary JavaScript, or receives a credential password. The
9+
typed API and runtime validators are generated from the repository's canonical SDK
10+
schema. SDK versions use semantic versioning independently of the wire protocol.
11+
12+
## Install
13+
14+
```sh
15+
python -m pip install lockintime-headless
16+
```
17+
18+
The `headless` CLI must already be installed and available on `PATH` when using
19+
`launch()`. `connect()` only attaches to an existing host and never shuts it down.
20+
21+
## Synchronous client
22+
23+
```python
24+
from headless_sdk import Untrusted, connect
25+
26+
with connect() as client:
27+
client.session_create(name="research")
28+
session = client.session("research")
29+
page = session.visit(url="https://example.com")
30+
assert isinstance(page, Untrusted)
31+
print(page.value["title"])
32+
```
33+
34+
The equivalent CLI flow is:
35+
36+
```sh
37+
headless start --background
38+
headless session create research
39+
headless --session research visit https://example.com
40+
```
41+
42+
## Asynchronous client
43+
44+
```python
45+
import asyncio
46+
47+
from headless_sdk import aconnect
48+
49+
50+
async def main() -> None:
51+
async with await aconnect() as client:
52+
session = await client.open_session("research")
53+
page = await session.inspect(text=True)
54+
print(page.value["text"])
55+
56+
57+
asyncio.run(main())
58+
```
59+
60+
Pass an `asyncio.Event` as `cancel=` or cancel the calling task. A cancellation or
61+
timeout before any bytes are written is retry-safe. Once request bytes have been
62+
written, timeout, cancellation, transport, framing, or response-validation failures
63+
raise `OperationOutcomeUnknown`. Do not retry that operation until you inspect host
64+
state.
65+
66+
## Supervised host ownership
67+
68+
```python
69+
from headless_sdk import launch
70+
71+
with launch(allow=["example.com"]) as host:
72+
print(host.client.host_status["pid"])
73+
```
74+
75+
`launch()` runs `headless start --supervised`, keeps the owner pipe open, and grants
76+
ownership only after the startup response PID matches the connected host PID.
77+
Closing the wrapper terminates and reaps only that owned launcher. It cannot adopt
78+
or stop a concurrently started shared host. Omit `presentation` to preserve the
79+
platform default. On macOS, pass `presentation="background"` or
80+
`presentation="foreground"` for an explicit override. Custom executable paths must
81+
be absolute.
82+
83+
## Authentication and untrusted data
84+
85+
Page-derived results are wrapped in `Untrusted[T]`; validate them before using them
86+
in privileged operations. `AUTH_REQUIRED` becomes `AuthenticationRequiredError` and
87+
its details are also untrusted. Login accepts only a challenge plus credential alias,
88+
or interactive mode:
89+
90+
```python
91+
from headless_sdk import AuthenticationRequiredError
92+
93+
try:
94+
session.click(role="button", name="Continue")
95+
except AuthenticationRequiredError as error:
96+
aliases = error.details.value["accounts"]
97+
session.auth_login(
98+
challenge=error.details.value["challenge"],
99+
account=aliases[0]["alias"],
100+
)
101+
```
102+
103+
There is intentionally no password parameter. Password enrollment remains a trusted
104+
CLI or native UI operation. Sensitive cookie and storage values still require both
105+
the command flag and the host's diagnostics environment gate.
106+
107+
## Compatibility and security limits
108+
109+
- The SDK requires the exact bundled wire protocol version. A mismatch fails before
110+
result decoding.
111+
- Capabilities are negotiated on connect. Missing commands raise
112+
`UnsupportedCapabilityError`; the SDK does not emulate them.
113+
- Socket paths must be direct children of `/tmp/headless-<uid>`. The directory and
114+
socket must be owned by the current user, non-symlinks, and private (`0700` and
115+
`0600`, respectively).
116+
- The transport accepts exactly one newline-terminated JSON response up to 1 MiB.
117+
- The same-user boundary prevents other OS users from connecting. It does not defend
118+
against a malicious process already running as your OS account.
119+
- `connect()` never owns the host. Stop shared hosts only through an explicit user
120+
action.
121+
122+
Report security problems according to the repository
123+
[security policy](https://github.com/LockInTime/headless/security/policy). Do not put
124+
secrets, cookies, private artifacts, or credential material in reports.
125+
126+
## Release controls
127+
128+
Python packages publish only from a `python-v<package-version>` tag whose commit is
129+
already merged into `main`. The GitHub `pypi` environment allows only `python-v*` tags
130+
and requires independent reviewer approval with administrator bypass disabled. PyPI
131+
trusted publishing is bound to that environment; no long-lived package token is stored
132+
in the repository.
133+
134+
## Development checks
135+
136+
From `packages/headless-python`:
137+
138+
```sh
139+
python -m pip install -e '.[dev]'
140+
python scripts/generate.py --check
141+
ruff check .
142+
ruff format --check .
143+
mypy
144+
pytest
145+
python -m build
146+
python scripts/verify_package.py
147+
```
148+
149+
Publishing uses PyPI trusted publishing from reviewed `python-v*` tags. The package
150+
has its own semantic version and is not released by Headless product `v*` tags.

0 commit comments

Comments
 (0)