Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Python CI

on:
push:
branches: [master, develop]
paths:
- 'python/**'
- 'nodejs/spec/hackmd-openapi.json'
- '.github/workflows/python-ci.yml'
pull_request:
paths:
- 'python/**'
- 'nodejs/spec/hackmd-openapi.json'
- '.github/workflows/python-ci.yml'

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: python

steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.2

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: python/pnpm-lock.yaml

- name: Set up uv and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: '0.12.18'
python-version: '3.13'
working-directory: python
cache-dependency-glob: python/uv.lock

- name: Install generator dependencies
run: pnpm install --frozen-lockfile

- name: Install Python dependencies
run: uv sync --frozen

- name: Check generated client
run: pnpm check:generated

- name: Compile Python sources
run: uv run --frozen pnpm check

- name: Run offline tests
run: pnpm test
3 changes: 3 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/node_modules/
/.venv/
__pycache__/
65 changes: 65 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Python API client experiment

An experimental Python API client generated from the same OpenAPI spec as the Node.js client. Not yet published or supported for production use.

## Setup

Requires Node.js 22.18+, pnpm 10.33.2, Python 3.10+, and [uv](https://docs.astral.sh/uv/). Run from `python/`:

```sh
pnpm install --frozen-lockfile
uv sync --frozen
```

## Usage

Save this as `example.py` and run `PYTHONPATH=src uv run python example.py` with `HACKMD_ACCESS_TOKEN` set:

```python
import os
from hackmd_api import API

with API(os.environ["HACKMD_ACCESS_TOKEN"]) as api:
notes = api.list_notes()
if notes:
note = api.get_note(notes[0].id)
print(note.title, note.content)

# Access any generated operation through api.raw.
response = api.raw.list_webhooks()
response.raise_for_status()
```

`API` covers profile, teams, history, personal notes/images, personal folders/order, and team note detail. All generated operations are available through `api.raw`.

- Methods return generated Pydantic models; no-content responses return `None`. Pass `unwrap_data=False` for the original HTTPX response.
- `get_note` and `get_team_note` accept `etag=...`; a conditional 304 returns `None`, so keep your cached note.
- Set `base_url` for a custom server or `retries=0` to disable retries. Only reads, PUT, and DELETE retry network errors, 429, or 5xx; POST/PATCH never retry.
- HTTP failures raise `HttpResponseError`. Raw operations do not parse responses, retry, or automatically raise HTTP errors.
- Use the exported `models` for request bodies. A create-note 207 returns `CreateNoteMultiStatusResponse`: the note was created, so do not retry creation.

## Development

```sh
pnpm codegen # regenerate the raw client
pnpm check:generated # detect generated-file drift
uv run --frozen pnpm check # compile Python sources
pnpm test # offline tests; no token needed
```

The custom layer lives in `src/hackmd_api/api.py`. Commit generated files under `src/hackmd_api/generated/`; never edit them by hand. Use these scripts rather than `npx` so the temporary [generator patch](./patches/README.md) is applied.

Python CI runs generation checks, compilation, and offline tests on Python 3.13. It does not run live tests.

## Live E2E

Reuse the ignored `nodejs/.env` with `HACKMD_ACCESS_TOKEN` and optional `HACKMD_API_ENDPOINT`. Environment variables take precedence.

```sh
HACKMD_E2E_MUTATIONS=0 pnpm test:e2e # read-only
HACKMD_E2E_MUTATIONS=1 pnpm test:e2e # note/folder CRUD, image upload, and ETag
```

Use a dedicated account and explicitly approve production writes. Never commit tokens or `.env`. Run Node and Python suites sequentially to avoid conflicting folder-order changes.

Tests clean up created notes/folders and restore folder order; cleanup failures fail the suite. Deleted notes remain in trash, and uploaded images may remain in storage. Unavailable folder endpoints are skipped; set `HACKMD_E2E_FOLDERS=0` to skip folder mutations.
5 changes: 5 additions & 0 deletions python/openapi-python.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
input: '../nodejs/spec/hackmd-openapi.json',
output: './src/hackmd_api/generated',
plugins: [{ name: '@hey-api/python-sdk', paramsStructure: 'flat' }],
};
16 changes: 16 additions & 0 deletions python/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "hackmd-python-codegen-experiment",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.2",
"scripts": {
"codegen": "openapi-python",
"check:generated": "node scripts/check-generated.mjs",
"check": "python3 -m compileall -q src/hackmd_api",
"test": "uv run --frozen python -m unittest discover -s tests -p '*.py'",
"test:e2e": "uv run --frozen --env-file ../nodejs/.env python tests/e2e/live.py"
},
"devDependencies": {
"@hey-api/openapi-python": "0.0.24"
}
}
2 changes: 2 additions & 0 deletions python/patches/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Unified diff context prefixes are significant, including on blank lines.
*.patch -whitespace
100 changes: 100 additions & 0 deletions python/patches/@hey-api__openapi-python@0.0.24.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
diff --git a/dist/clients/httpx/client.py b/dist/clients/httpx/client.py
index 2980b579df301513f3a9940b4a1af66946e27bb3..acf8110e65dd62bc9bd276b0daf0a706fdb7b9bc 100644
--- a/dist/clients/httpx/client.py
+++ b/dist/clients/httpx/client.py
@@ -1,6 +1,7 @@
from typing import Any, Optional
-import httpx
+from urllib.parse import quote

+import httpx

EXTRA_PREFIXES_MAP = {
"$body_": "json",
@@ -10,7 +11,7 @@ EXTRA_PREFIXES_MAP = {
}


-def build_client_params(fields: list[dict[str, Any]], **kwargs) -> dict[str, Any]:
+def build_client_params(fields: list[dict[str, Any]], /, **kwargs) -> dict[str, Any]:
"""Build client parameters from flat keyword arguments.

Args:
@@ -40,9 +41,9 @@ def build_client_params(fields: list[dict[str, Any]], **kwargs) -> dict[str, Any
if field:
in_slot = field["in"]
map_key = field["map"]
- slot = "json" if in_slot == "body" else in_slot
+ slot = {"body": "json", "query": "params"}.get(in_slot, in_slot)

- if in_slot == "body":
+ if in_slot == "body" and map_key == "body":
result[slot] = value
else:
if slot not in result:
@@ -61,8 +62,8 @@ def build_client_params(fields: list[dict[str, Any]], **kwargs) -> dict[str, Any
result["params"] = {}
result["params"][key] = value

- for slot in list(result.keys()):
- if not result[slot]:
+ for slot in ("headers", "params", "path"):
+ if slot in result and not result[slot]:
del result[slot]

return result
@@ -86,6 +87,29 @@ class BaseClient:
"""Make an HTTP request."""
return self._client.request(method, url, **kwargs)

+ def request_options(
+ self,
+ method: str,
+ url: str,
+ options: Optional[dict[str, Any]] = None,
+ overrides: Optional[dict[str, Any]] = None,
+ **kwargs,
+ ) -> httpx.Response:
+ """Make an HTTP request."""
+ request_options = dict(options or {})
+ request_options.update(overrides or {})
+ if "files" in request_options or "content" in request_options:
+ request_options.pop("json", None)
+ path = request_options.pop("path", {})
+ for key, value in path.items():
+ url = url.replace(f"{{{key}}}", quote(str(value), safe=""))
+
+ body = request_options.get("json")
+ if hasattr(body, "model_dump"):
+ request_options["json"] = body.model_dump(mode="json", by_alias=True, exclude_unset=True)
+
+ return self.request(method, url, **request_options, **kwargs)
+
def get(self, url: str, **kwargs) -> httpx.Response:
"""Make a GET request."""
return self._client.get(url, **kwargs)
diff --git a/dist/src-DaXm5pxY.mjs b/dist/src-DaXm5pxY.mjs
index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..175e17904bb0425037f3f44d8f86a874f3b7a08d 100644
--- a/dist/src-DaXm5pxY.mjs
+++ b/dist/src-DaXm5pxY.mjs
@@ -4290,7 +4290,7 @@ function implementFn(args) {
if (field.map) fieldDict.entry($$1.literal("map"), $$1.literal(field.map));
fieldsList.element(fieldDict);
}
- return node.params(...opParameters.parameters).do($$1.var("params").assign($$1(plugin.imports.buildClientParams).call(fieldsList, ...paramNames.map((name) => $$1.kwarg(name, name))))).do($$1("self").attr("client").attr(method).call($$1.literal(operation.path), $$1.kwarg("params", $$1("params"))).return());
+ return node.params(...opParameters.parameters, $$1.param("request_overrides").type($$1.type.or($$1("dict").slice("str", plugin.imports.typing.Any), "None")).default("None")).do($$1.var("params").assign($$1(plugin.imports.buildClientParams).call(fieldsList, ...paramNames.map((name) => $$1.kwarg(name, $$1(name)))))).do($$1("self").attr("request_options").call($$1.literal(method), $$1.literal(operation.path), $$1("params"), $$1("request_overrides")).return());
}
return node.params(...opParameters.parameters).do($$1("self").attr("client").attr(method).call($$1.literal(operation.path)).return());
}
@@ -5245,8 +5245,9 @@ function booleanToType({ path, plugin, schema }) {
//#region src/plugins/pydantic/v2/toAst/enum.ts
function toEnumMemberName(value) {
if (typeof value === "boolean") return toCase(String(value), "SCREAMING_SNAKE_CASE");
- if (typeof value === "number") return `VALUE_${value}`.replace(/-/g, "_NEG_").replace(/\./g, "_DOT_");
- return toCase(value, "SCREAMING_SNAKE_CASE");
+ if (typeof value === "number") return safeRuntimeName(`VALUE_${value}`.replace(/-/g, "_NEG_").replace(/\./g, "_DOT_"));
+ const name = toCase(value, "SCREAMING_SNAKE_CASE");
+ return safeRuntimeName(/^[A-Z_]/.test(name) ? name : `VALUE_${name || "EMPTY"}`);
}
function itemsNode(ctx) {
const { plugin, schema } = ctx;
17 changes: 17 additions & 0 deletions python/patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Temporary generator patch

`@hey-api/openapi-python@0.0.24` is pinned and patched through `pnpm-workspace.yaml`. The patch adapts the runtime changes from these upstream PRs to the published package:

- [hey-api/hey-api#4441](https://github.com/hey-api/hey-api/pull/4441), commit `52ab0530832013a047ed4f867e08a94ccc7f0426`: legal Python enum member names without changing wire values.
- [hey-api/hey-api#4290](https://github.com/hey-api/hey-api/pull/4290), commit `1a7b6d75d246606aecd92b5d3a3e2d72748a17ed`: pass flat argument values, substitute URL paths, route query/JSON fields, serialize Pydantic bodies, and avoid the `fields` helper-name collision.

Only the shipped JavaScript bundle and HTTPX template are patched; generated output is never patched. No grouped implementation or SDK parameter-name normalization is included. Source maps remain those of the original npm release.

Local additions for the wrapper (not part of those upstream PRs):

- Parameterized flat methods accept `request_overrides` for per-call HTTPX headers/serialization. The wrapper uses this for ETag, multipart uploads, and complete PATCH bodies without duplicating endpoint paths. `files`/`content` replaces JSON serialization.
- Pydantic bodies use `exclude_unset=True`, preserving explicit nulls without sending null for every omitted field. Raw inline optional parameters still conflate omitted values with `None`; use a full JSON body override when that distinction matters.

These are experiment-local compatibility changes, not a general multipart or unset-value implementation in the generator. Keep them until upstream provides equivalent transport options and serialization; merging the two PRs alone does not cover these additions.

Remove each part when an upstream release contains its fix, then update the pinned version/lockfile and rerun codegen, compile, and tests. PR numbers alone do not guarantee a released package contains the fixes.
Loading
Loading