Skip to content

Commit dfe110d

Browse files
committed
feat: add Python API wrapper and live e2e tests
1 parent 7d3714c commit dfe110d

13 files changed

Lines changed: 925 additions & 110 deletions

File tree

‎python/README.md‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,83 @@ The pinned generator includes a temporary [pnpm patch](./patches/README.md) adap
1616

1717
Like the Node.js client, `codegen` generates sources and `check:generated` checks them. Commit regenerated files alongside spec, config, or generator changes. The check regenerates the package and fails if generated files were added, removed, or changed, ignoring Python bytecode caches.
1818

19-
`src/hackmd_api/__init__.py` is the handwritten package entry point; it currently re-exports the generated `Sdk`. Everything under `src/hackmd_api/generated/` belongs to the generator. Future handwritten wrappers must live outside that directory so regeneration cannot overwrite them. This mirrors `nodejs/src/index.ts` and `nodejs/src/generated/`, with the extra `hackmd_api` directory providing the Python package namespace.
19+
`src/hackmd_api/__init__.py` exports the handwritten `API`, generated `Sdk`, and generated `models`. Everything under `src/hackmd_api/generated/` belongs to the generator; the custom layer lives in `api.py`. This mirrors `nodejs/src/index.ts` and `nodejs/src/generated/`, with the extra `hackmd_api` directory providing the Python package namespace.
2020

2121
The smoke test imports the generated package and checks the operation count against the spec, reaction enum values, personal/team paths, query parameters, JSON/Pydantic bodies, and raw responses (including 204, 304, 207, and 404). It uses HTTPX MockTransport, a custom base URL, and a fake bearer token: no network requests or real credentials are used. This is representative coverage, not verification of every operation against a live server.
2222

23-
The SDK still returns `httpx.Response`; callers read `.json()` or explicitly call `.raise_for_status()`. Authentication is configured on an injected `httpx.Client`, not generated from security schemes. Grouped parameters, multipart uploads, automatic response parsing, and production readiness remain outside this experiment.
23+
## Custom API
24+
25+
Run local examples with `PYTHONPATH=src uv run python your_example.py`; this experiment is not yet packaged for installation.
26+
27+
```python
28+
import os
29+
from hackmd_api import API, models
30+
31+
with API(os.environ["HACKMD_ACCESS_TOKEN"]) as api:
32+
notes = api.list_notes()
33+
if notes:
34+
note = api.get_note(notes[0].id)
35+
print(note.title, note.content)
36+
37+
# All generated operations remain accessible; raw returns httpx.Response.
38+
response = api.raw.list_webhooks()
39+
response.raise_for_status()
40+
```
41+
42+
The first wrapper slice covers profile, teams, history, personal notes CRUD/images, personal folders CRUD/order, and team note detail. Methods use snake_case and generated Pydantic models, not a second set of handwritten DTOs. Other operations remain on `api.raw`.
43+
44+
Unlike Node.js's compile-time-only types, Python parses and validates response bodies. A response that disagrees with the spec raises a Pydantic `ValidationError`; it is not silently coerced into an untyped dictionary. Generated named scalar schemas use `RootModel` (for example, `folder.name.root`). Request models with aliases can be constructed using wire names via `models.CreateUserFolderBody.model_validate({"name": "Folder", "parentFolderId": "..."})`.
45+
46+
The following snippets belong inside the `with API(...) as api:` block above.
47+
48+
```python
49+
# Mutating example: only run against an account you intend to modify.
50+
created = api.create_note(models.CreateNote(title="Example", content="# Hello"))
51+
if isinstance(created, models.CreateNoteMultiStatusResponse):
52+
# HTTP 207 still created a note; do not retry the creation.
53+
print(created.error)
54+
note_id = created.note.id
55+
else:
56+
note_id = created.id
57+
58+
api.update_note(note_id, models.UpdateNoteBody(title="Updated"))
59+
# Unset fields are omitted; explicit None is serialized as JSON null.
60+
api.update_note(note_id, models.UpdateNoteBody.model_validate({"parentFolderId": None}))
61+
api.delete_note(note_id)
62+
```
63+
64+
`API(token, base_url="https://api-stage.hackmd.io/v1", timeout=30, retries=3)` owns its HTTPX client. Timeout and retry delay are in seconds. Only reads, PUT, and DELETE retry transport errors, 429, or 5xx; POST/PATCH never retry automatically. Exhausted rate-limit headers stop retries. `retries=0` disables them. A retried DELETE may return 404 if the first attempt already succeeded.
65+
66+
HTTP errors raise `HttpResponseError` (with `code` and the original `response`), `TooManyRequestsError`, or `InternalServerError`. `wrap_response_errors=False` retains HTTPX's `HTTPStatusError`; transport errors retain their HTTPX type. Raw operations use the same authentication/base URL, but do not retry, parse, or automatically raise errors.
67+
68+
### ETag and raw responses
69+
70+
```python
71+
cached = api.get_note(note_id, unwrap_data=False)
72+
cached_note = models.SingleNote.model_validate_json(cached.content)
73+
etag = cached.headers.get("ETag")
74+
note = api.get_note(note_id, etag=etag)
75+
if note is None: # HTTP 304: keep your cached data
76+
note = cached_note
77+
```
78+
79+
`get_team_note(team_path, note_id, etag=...)` behaves the same. A 304 is accepted only for conditional requests. No-content 202/204/304 returns `None`; `unwrap_data=False` preserves the original HTTPX response and status/headers. Images use `upload_note_image(note_id, image_bytes, filename="image.png", content_type="image/png")` with a multipart serializer passed to the generated operation.
80+
81+
## Live E2E
82+
83+
The suite mirrors all existing Node live scenarios (profile, lists, history, note CRUD/image, folder CRUD/nesting/order) and also checks note `200 → 304 → changed 200`. It groups dependent CRUD steps into two workflows rather than separate tests. Offline `pnpm test` never discovers or runs it.
84+
85+
From `python/`, reuse the ignored `nodejs/.env` (`HACKMD_ACCESS_TOKEN`, optional `HACKMD_API_ENDPOINT`). Real environment variables override values in that file:
86+
87+
```sh
88+
HACKMD_E2E_MUTATIONS=0 pnpm test:e2e # read-only
89+
HACKMD_E2E_MUTATIONS=1 pnpm test:e2e # creates/deletes notes, folders; uploads an image
90+
```
91+
92+
For environment-only/CI credentials, use `uv run --frozen python tests/e2e/live.py`. Never commit a token or `.env`. Use a dedicated account, and explicitly authorize production writes before running them. Run Node and Python suites sequentially, without concurrent folder-order edits.
93+
94+
Resources are tracked before DTO assertions; folder order is restored before cleanup on failure. Cleanup errors fail the suite. Notes are moved to trash, **not permanently deleted**, and deleting a note does not prove its uploaded image was removed from storage. Folder endpoints unavailable on the target are reported as skips; `HACKMD_E2E_FOLDERS=0` disables folder mutations.
95+
96+
The shared spec allows `Team.ownerId` to be null, matching ownerless teams in production. Regression tests cover both string and null values in profiles and team lists. The current Python generator also defaults nullable fields to `None` when omitted, so it does not yet enforce the spec's required-vs-nullable distinction as strictly as the TypeScript output.
97+
98+
Grouped parameters, async wrappers, packaging/publication, and wrappers for all operations remain outside this first slice.

‎python/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"codegen": "openapi-python",
88
"check:generated": "node scripts/check-generated.mjs",
99
"check": "python3 -m compileall -q src/hackmd_api",
10-
"test": "uv run --frozen python tests/smoke.py"
10+
"test": "uv run --frozen python -m unittest discover -s tests -p '*.py'",
11+
"test:e2e": "uv run --frozen --env-file ../nodejs/.env python tests/e2e/live.py"
1112
},
1213
"devDependencies": {
1314
"@hey-api/openapi-python": "0.0.24"

‎python/patches/@hey-api__openapi-python@0.0.24.patch‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
diff --git a/dist/clients/httpx/client.py b/dist/clients/httpx/client.py
2-
index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce95f89ef7 100644
2+
index 2980b579df301513f3a9940b4a1af66946e27bb3..acf8110e65dd62bc9bd276b0daf0a706fdb7b9bc 100644
33
--- a/dist/clients/httpx/client.py
44
+++ b/dist/clients/httpx/client.py
55
@@ -1,6 +1,7 @@
@@ -43,7 +43,7 @@ index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce
4343
del result[slot]
4444

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

@@ -52,33 +52,37 @@ index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce
5252
+ method: str,
5353
+ url: str,
5454
+ options: Optional[dict[str, Any]] = None,
55+
+ overrides: Optional[dict[str, Any]] = None,
5556
+ **kwargs,
5657
+ ) -> httpx.Response:
5758
+ """Make an HTTP request."""
5859
+ request_options = dict(options or {})
60+
+ request_options.update(overrides or {})
61+
+ if "files" in request_options or "content" in request_options:
62+
+ request_options.pop("json", None)
5963
+ path = request_options.pop("path", {})
6064
+ for key, value in path.items():
6165
+ url = url.replace(f"{{{key}}}", quote(str(value), safe=""))
6266
+
6367
+ body = request_options.get("json")
6468
+ if hasattr(body, "model_dump"):
65-
+ request_options["json"] = body.model_dump(mode="json", by_alias=True)
69+
+ request_options["json"] = body.model_dump(mode="json", by_alias=True, exclude_unset=True)
6670
+
6771
+ return self.request(method, url, **request_options, **kwargs)
6872
+
6973
def get(self, url: str, **kwargs) -> httpx.Response:
7074
"""Make a GET request."""
7175
return self._client.get(url, **kwargs)
7276
diff --git a/dist/src-DaXm5pxY.mjs b/dist/src-DaXm5pxY.mjs
73-
index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..7b024dd366c0384d4f3b5a4cf8ab55cbbf74f0a1 100644
77+
index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..175e17904bb0425037f3f44d8f86a874f3b7a08d 100644
7478
--- a/dist/src-DaXm5pxY.mjs
7579
+++ b/dist/src-DaXm5pxY.mjs
7680
@@ -4290,7 +4290,7 @@ function implementFn(args) {
7781
if (field.map) fieldDict.entry($$1.literal("map"), $$1.literal(field.map));
7882
fieldsList.element(fieldDict);
7983
}
8084
- 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());
81-
+ return node.params(...opParameters.parameters).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")).return());
85+
+ 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());
8286
}
8387
return node.params(...opParameters.parameters).do($$1("self").attr("client").attr(method).call($$1.literal(operation.path)).return());
8488
}

‎python/patches/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,11 @@
77

88
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.
99

10-
Remove the patch when an upstream release contains both fixes, then update the pinned version/lockfile and rerun codegen, compile, and smoke tests. PR numbers alone do not guarantee a released package contains the fixes.
10+
Local additions for the wrapper (not part of those upstream PRs):
11+
12+
- 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.
13+
- 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.
14+
15+
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.
16+
17+
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.

‎python/pnpm-lock.yaml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎python/src/hackmd_api/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Experimental HackMD API client."""
22

33
from .generated import Sdk
4+
from .generated import pydantic_gen as models
5+
from .api import API, HttpResponseError, InternalServerError, TooManyRequestsError
46

5-
__all__ = ["Sdk"]
7+
__all__ = ["API", "Sdk", "models", "HttpResponseError", "InternalServerError", "TooManyRequestsError"]

0 commit comments

Comments
 (0)