diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml
index 69c3b23..1b82c0c 100644
--- a/.github/workflows/publish-python.yml
+++ b/.github/workflows/publish-python.yml
@@ -4,44 +4,125 @@ on:
workflow_dispatch:
inputs:
dry_run:
- description: "Dry run"
+ description: "Build and verify without publishing"
required: false
default: "true"
+permissions:
+ contents: read
+
jobs:
- publish:
- name: Publish Python package
+ build:
+ name: Build and verify Python distributions
runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.metadata.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- - name: Require PyPI publisher credential
- if: inputs.dry_run != 'true'
- env:
- TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
- run: |
- if [ -z "$TWINE_PASSWORD" ]; then
- echo "::error::PYPI_API_TOKEN is unavailable for this publisher"
- exit 1
- fi
- - name: Install maturin
- run: pip install maturin twine
- - name: Build wheels
- run: cd crates/markkit-python && maturin build --release
+ - name: Install packaging tools
+ run: python -m pip install maturin==1.15.0 twine==7.0.0
+ - name: Build from source distribution
+ run: maturin build --release --locked --sdist
- name: Verify build preserved the patched lockfile
run: git diff --exit-code -- Cargo.lock
- - name: Verify wheel metadata and native import
+ - name: Inspect distribution identity
+ id: metadata
run: |
- twine check target/wheels/*.whl
+ python <<'PYTHON'
+ import email.parser
+ import os
+ import pathlib
+ import zipfile
+ wheels = list(pathlib.Path("target/wheels").glob("*.whl"))
+ assert len(wheels) == 1
+ with zipfile.ZipFile(wheels[0]) as wheel:
+ metadata = email.parser.BytesParser().parsebytes(wheel.read(next(name for name in wheel.namelist() if name.endswith(".dist-info/METADATA"))))
+ assert metadata["Name"] == "markkit"
+ with open(os.environ["GITHUB_OUTPUT"], "a") as output:
+ print(f"version={metadata['Version']}", file=output)
+ print(f"markkit {metadata['Version']} from {os.environ['GITHUB_SHA']}")
+ PYTHON
+ - name: Verify distributions and native import
+ run: |
+ python -m twine check --strict target/wheels/*.whl target/wheels/*.tar.gz
python -m venv "$RUNNER_TEMP/markkit-wheel-smoke"
"$RUNNER_TEMP/markkit-wheel-smoke/bin/python" -m pip install --no-deps target/wheels/*.whl
- "$RUNNER_TEMP/markkit-wheel-smoke/bin/python" -c 'import markkit_python; assert markkit_python.extract_html_sync("
Smoke
").strip() == "# Smoke"'
- - name: Publish
- if: inputs.dry_run != 'true'
- run: twine upload target/wheels/*.whl
- env:
- TWINE_USERNAME: __token__
- TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+ "$RUNNER_TEMP/markkit-wheel-smoke/bin/python" -c 'import importlib.metadata, importlib.util, markkit; assert importlib.metadata.metadata("markkit")["Name"] == "markkit"; assert importlib.util.find_spec("markkit_python") is None; assert markkit.extract_html_sync("Smoke
").strip() == "# Smoke"'
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: python-distributions
+ path: |
+ target/wheels/*.whl
+ target/wheels/*.tar.gz
+ if-no-files-found: error
+
+ publish:
+ name: Publish Python package with OIDC
+ needs: build
+ if: inputs.dry_run != 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: python-distributions
+ path: dist
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
+ with:
+ packages-dir: dist/
+
+ verify:
+ name: Verify public PyPI installation
+ needs: [build, publish]
+ runs-on: ubuntu-latest
+ env:
+ MARKKIT_VERSION: ${{ needs.build.outputs.version }}
+ steps:
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: python-distributions
+ path: dist
+ - name: Download the published wheel
+ run: |
+ for attempt in 1 2 3 4 5; do
+ if python -m pip download --no-cache-dir --only-binary=:all: --no-deps --dest registry "markkit==$MARKKIT_VERSION"; then
+ break
+ fi
+ if [ "$attempt" -eq 5 ]; then exit 1; fi
+ sleep 10
+ done
+ - name: Verify public files match the build
+ run: |
+ python <<'PYTHON'
+ import hashlib
+ import json
+ import os
+ import pathlib
+ import urllib.request
+ version = os.environ["MARKKIT_VERSION"]
+ with urllib.request.urlopen(f"https://pypi.org/pypi/markkit/{version}/json") as response:
+ published = json.load(response)
+ assert published["info"]["name"] == "markkit"
+ assert published["info"]["version"] == version
+ digests = {entry["filename"]: entry["digests"]["sha256"] for entry in published["urls"]}
+ for artifact in pathlib.Path("dist").iterdir():
+ assert digests[artifact.name] == hashlib.sha256(artifact.read_bytes()).hexdigest()
+ for wheel in pathlib.Path("registry").glob("*.whl"):
+ assert digests[wheel.name] == hashlib.sha256(wheel.read_bytes()).hexdigest()
+ print(f"Public markkit {version} distributions match the build")
+ PYTHON
+ - name: Install and smoke-test the public wheel
+ run: |
+ python -m venv "$RUNNER_TEMP/markkit-public-smoke"
+ "$RUNNER_TEMP/markkit-public-smoke/bin/python" -m pip install --no-index --no-deps registry/*.whl
+ "$RUNNER_TEMP/markkit-public-smoke/bin/python" -c 'import importlib.metadata, importlib.util, markkit; assert importlib.metadata.version("markkit") == __import__("os").environ["MARKKIT_VERSION"]; assert importlib.util.find_spec("markkit_python") is None; assert markkit.extract_html_sync("Published smoke
").strip() == "# Published smoke"; print("Public markkit import and native extraction PASS")'
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index fd725f4..4c33715 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -25,8 +25,8 @@ jobs:
- name: Install maturin
run: pip install maturin
- name: Build
- run: cd crates/markkit-python && maturin build
+ run: maturin build
- name: Test import
run: |
pip install target/wheels/*.whl
- python -c "import markkit_python; print('Import OK')"
+ python -c 'from importlib.metadata import metadata; import markkit; assert metadata("markkit")["Name"] == "markkit"; assert markkit.extract_html_sync("Smoke
").strip() == "# Smoke"; print("Distribution and import OK")'
diff --git a/README.md b/README.md
index 04cb16c..47939c6 100644
--- a/README.md
+++ b/README.md
@@ -62,10 +62,10 @@ markkit extract https://example.com --json
### Python
```python
-import markkit_python
+import markkit
-markdown = await markkit_python.extract_html("Hello
")
-markdown = await markkit_python.extract_url("https://example.com")
+markdown = await markkit.extract_html("Hello
")
+markdown = await markkit.extract_url("https://example.com")
```
### Node.js
diff --git a/crates/markkit-python/README.md b/crates/markkit-python/README.md
index fff75d2..3b4ca3b 100644
--- a/crates/markkit-python/README.md
+++ b/crates/markkit-python/README.md
@@ -1,17 +1,29 @@
-# markkit-python
+# Markkit for Python
Native Python bindings for Markkit HTML-to-Markdown extraction.
+The PyPI distribution and Python import are both named `markkit`
+(`pip install markkit`, then `import markkit`). The Rust crate remains
+`markkit-python`.
+
The current wheel publisher builds for **Linux x64 and CPython 3.12**. It does not
-publish macOS, Windows, ARM64 or other Python-version wheels. Other wheel targets
-require a separate future release; Rust and npm availability is independent.
+publish macOS, Windows, ARM64 or other Python-version wheels. A source
+distribution is also published; source builds require Rust and native build
+tools and are validated with Rust 1.99. Other wheel targets require a separate
+future release; Rust and npm availability is independent.
```python
-import markkit_python
+import markkit
-print(markkit_python.extract_html_sync("Hello
"))
+print(markkit.extract_html_sync("Hello
"))
# # Hello
```
Publication verifies wheel metadata, installs the actual wheel into a clean
virtual environment and exercises native extraction before upload.
+
+The manual publisher uses PyPI Trusted Publishing (OIDC), without a stored
+PyPI API token. A dry run builds and verifies distributions without publishing.
+
+Python packaging is configured by the workspace-root `pyproject.toml`. Source
+archives retain the complete Cargo workspace and its unchanged security lockfile.
diff --git a/crates/markkit-python/src/lib.rs b/crates/markkit-python/src/lib.rs
index fab7641..7f3c744 100644
--- a/crates/markkit-python/src/lib.rs
+++ b/crates/markkit-python/src/lib.rs
@@ -79,7 +79,8 @@ fn extract_bytes(
}
#[pymodule]
-fn markkit_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
+#[pyo3(name = "markkit")]
+fn markkit_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(extract_html, m)?)?;
m.add_function(wrap_pyfunction!(extract_html_sync, m)?)?;
m.add_function(wrap_pyfunction!(extract_html_sync_with_options, m)?)?;
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..3bf8cf4
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,16 @@
+[build-system]
+requires = ["maturin>=1,<2"]
+build-backend = "maturin"
+
+[project]
+name = "markkit"
+dynamic = ["version", "description", "authors", "license"]
+readme = "crates/markkit-python/README.md"
+
+[tool.maturin]
+manifest-path = "crates/markkit-python/Cargo.toml"
+module-name = "markkit"
+# Preserve all workspace members so the security lockfile stays valid in sdists.
+sdist-generator = "git"
+locked = true
+exclude = [{ path = ".claude/skills", format = "sdist" }]