From 50c988c126b8e505c297575501a41bb6c868f182 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 27 Sep 2026 00:53:23 +0800 Subject: [PATCH 1/7] chore: explore Python API client codegen --- python/.gitignore | 1 + python/README.md | 12 ++++++++++++ python/openapi-python.config.mjs | 5 +++++ 3 files changed, 18 insertions(+) create mode 100644 python/.gitignore create mode 100644 python/README.md create mode 100644 python/openapi-python.config.mjs diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..962b10f --- /dev/null +++ b/python/.gitignore @@ -0,0 +1 @@ +/.generated/ diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..1253bff --- /dev/null +++ b/python/README.md @@ -0,0 +1,12 @@ +# Python API client experiment + +This is a code generation experiment, not a usable or published Python API client. It reads the same vendored OpenAPI spec as the Node.js client; generated files are ignored and should not be edited. + +Requires Node.js 22.18 or newer. From this directory: + +```sh +npx --yes @hey-api/openapi-python@0.0.24 +python3 -m compileall -q .generated +``` + +Generation currently completes for all 59 v1 operations, but the compile check fails: the `+1` and `-1` reaction values become invalid Python enum member names. The generated SDK also does not yet make parameterized calls correctly: `get_note(noteId)` leaves `{noteId}` in the URL and passes its path parameter as HTTPX query parameters. Keep this as an experiment until generated code compiles and a mocked request confirms authentication, path substitution, and response handling. diff --git a/python/openapi-python.config.mjs b/python/openapi-python.config.mjs new file mode 100644 index 0000000..ee7cd46 --- /dev/null +++ b/python/openapi-python.config.mjs @@ -0,0 +1,5 @@ +export default { + input: '../nodejs/spec/hackmd-openapi.json', + output: './.generated', + plugins: [{ name: '@hey-api/python-sdk', paramsStructure: 'flat' }], +}; From 472483663a0613c2b1309d6bb8deac2840a62bfd Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 27 Sep 2026 22:11:50 +0800 Subject: [PATCH 2/7] feat: generate patched Python API client --- python/.gitignore | 4 +- python/README.md | 18 +- python/openapi-python.config.mjs | 2 +- python/package.json | 14 + python/patches/.gitattributes | 2 + .../@hey-api__openapi-python@0.0.24.patch | 96 + python/patches/README.md | 10 + python/pnpm-lock.yaml | 396 ++++ python/pnpm-workspace.yaml | 2 + python/scripts/check-generated.mjs | 55 + python/src/hackmd_api/__init__.py | 5 + python/src/hackmd_api/generated/__init__.py | 5 + .../hackmd_api/generated/client/__init__.py | 5 + .../hackmd_api/generated/client/client_gen.py | 150 ++ .../src/hackmd_api/generated/pydantic_gen.py | 1586 +++++++++++++++++ python/src/hackmd_api/generated/sdk_gen.py | 463 +++++ 16 files changed, 2806 insertions(+), 7 deletions(-) create mode 100644 python/package.json create mode 100644 python/patches/.gitattributes create mode 100644 python/patches/@hey-api__openapi-python@0.0.24.patch create mode 100644 python/patches/README.md create mode 100644 python/pnpm-lock.yaml create mode 100644 python/pnpm-workspace.yaml create mode 100644 python/scripts/check-generated.mjs create mode 100644 python/src/hackmd_api/__init__.py create mode 100644 python/src/hackmd_api/generated/__init__.py create mode 100644 python/src/hackmd_api/generated/client/__init__.py create mode 100644 python/src/hackmd_api/generated/client/client_gen.py create mode 100644 python/src/hackmd_api/generated/pydantic_gen.py create mode 100644 python/src/hackmd_api/generated/sdk_gen.py diff --git a/python/.gitignore b/python/.gitignore index 962b10f..988b1af 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -1 +1,3 @@ -/.generated/ +/node_modules/ +/.venv/ +__pycache__/ diff --git a/python/README.md b/python/README.md index 1253bff..846f711 100644 --- a/python/README.md +++ b/python/README.md @@ -1,12 +1,20 @@ # Python API client experiment -This is a code generation experiment, not a usable or published Python API client. It reads the same vendored OpenAPI spec as the Node.js client; generated files are ignored and should not be edited. +This is a code generation experiment, not a supported or published Python API client. It reads the same vendored OpenAPI spec as the Node.js client and uses flat parameters. Generated files in `src/hackmd_api/generated/` belong in version control and must not be edited by hand. -Requires Node.js 22.18 or newer. From this directory: +Requires Node.js 22.18+, pnpm 10.33.2, and Python 3.10+. From this directory: ```sh -npx --yes @hey-api/openapi-python@0.0.24 -python3 -m compileall -q .generated +pnpm install --frozen-lockfile +pnpm codegen +pnpm check:generated +pnpm check ``` -Generation currently completes for all 59 v1 operations, but the compile check fails: the `+1` and `-1` reaction values become invalid Python enum member names. The generated SDK also does not yet make parameterized calls correctly: `get_note(noteId)` leaves `{noteId}` in the URL and passes its path parameter as HTTPX query parameters. Keep this as an experiment until generated code compiles and a mocked request confirms authentication, path substitution, and response handling. +The pinned generator includes a temporary [pnpm patch](./patches/README.md) adapting two pending upstream fixes. Use the commands above rather than `npx`, which bypasses the patch. + +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. + +`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. + +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. diff --git a/python/openapi-python.config.mjs b/python/openapi-python.config.mjs index ee7cd46..6e8d24d 100644 --- a/python/openapi-python.config.mjs +++ b/python/openapi-python.config.mjs @@ -1,5 +1,5 @@ export default { input: '../nodejs/spec/hackmd-openapi.json', - output: './.generated', + output: './src/hackmd_api/generated', plugins: [{ name: '@hey-api/python-sdk', paramsStructure: 'flat' }], }; diff --git a/python/package.json b/python/package.json new file mode 100644 index 0000000..fabd775 --- /dev/null +++ b/python/package.json @@ -0,0 +1,14 @@ +{ + "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" + }, + "devDependencies": { + "@hey-api/openapi-python": "0.0.24" + } +} diff --git a/python/patches/.gitattributes b/python/patches/.gitattributes new file mode 100644 index 0000000..19b385a --- /dev/null +++ b/python/patches/.gitattributes @@ -0,0 +1,2 @@ +# Unified diff context prefixes are significant, including on blank lines. +*.patch -whitespace diff --git a/python/patches/@hey-api__openapi-python@0.0.24.patch b/python/patches/@hey-api__openapi-python@0.0.24.patch new file mode 100644 index 0000000..a0ce80d --- /dev/null +++ b/python/patches/@hey-api__openapi-python@0.0.24.patch @@ -0,0 +1,96 @@ +diff --git a/dist/clients/httpx/client.py b/dist/clients/httpx/client.py +index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce95f89ef7 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,25 @@ 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, ++ **kwargs, ++ ) -> httpx.Response: ++ """Make an HTTP request.""" ++ request_options = dict(options or {}) ++ 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) ++ ++ 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..7b024dd366c0384d4f3b5a4cf8ab55cbbf74f0a1 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).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()); + } + 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; diff --git a/python/patches/README.md b/python/patches/README.md new file mode 100644 index 0000000..00acf9f --- /dev/null +++ b/python/patches/README.md @@ -0,0 +1,10 @@ +# 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. + +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. diff --git a/python/pnpm-lock.yaml b/python/pnpm-lock.yaml new file mode 100644 index 0000000..99635a3 --- /dev/null +++ b/python/pnpm-lock.yaml @@ -0,0 +1,396 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +patchedDependencies: + '@hey-api/openapi-python@0.0.24': + hash: 10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189 + path: patches/@hey-api__openapi-python@0.0.24.patch + +importers: + + .: + devDependencies: + '@hey-api/openapi-python': + specifier: 0.0.24 + version: 0.0.24(patch_hash=10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189) + +packages: + + '@hey-api/codegen-core@0.9.1': + resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.4': + resolution: {integrity: sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-python@0.0.24': + resolution: {integrity: sha512-UbB0yObI1vTWxp6PICptIs2F24s6JFDTS2og5qDBj4cFEtrJWd5wpHDAedIiEHR3XgKVxUfpZP0iBx7mZKy/sw==} + engines: {node: '>=22.18.0'} + hasBin: true + + '@hey-api/shared@0.5.0': + resolution: {integrity: sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + +snapshots: + + '@hey-api/codegen-core@0.9.1': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.4': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@hey-api/openapi-python@0.0.24(patch_hash=10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189)': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/shared': 0.5.0 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 15.0.0 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.5.0': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + + '@jsdevtools/ono@7.1.3': {} + + '@lukeed/ms@2.0.2': {} + + '@types/json-schema@7.0.15': {} + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + color-support@1.1.3: {} + + commander@15.0.0: {} + + confbox@0.2.4: {} + + confbox@0.3.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + + dotenv@17.4.2: {} + + exsolve@1.1.1: {} + + giget@3.3.1: {} + + is-docker@3.0.0: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + ohash@2.0.12: {} + + open@11.0.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.1 + pathe: 2.0.3 + + powershell-utils@0.1.0: {} + + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@5.1.1: {} + + run-applescript@7.1.0: {} + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 diff --git a/python/pnpm-workspace.yaml b/python/pnpm-workspace.yaml new file mode 100644 index 0000000..ee24905 --- /dev/null +++ b/python/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +patchedDependencies: + '@hey-api/openapi-python@0.0.24': patches/@hey-api__openapi-python@0.0.24.patch diff --git a/python/scripts/check-generated.mjs b/python/scripts/check-generated.mjs new file mode 100644 index 0000000..b179389 --- /dev/null +++ b/python/scripts/check-generated.mjs @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readdir, readFile } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Follow the Node.js client's before/after manifest check, including untracked files. +const packageRoot = fileURLToPath(new URL('../', import.meta.url)) +const generatedRoot = join(packageRoot, 'src/hackmd_api/generated') + +async function filesIn (directory) { + const files = [] + let entries + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch (error) { + if (error.code === 'ENOENT') return files + throw error + } + for (const entry of entries) { + if (entry.name === '__pycache__' || entry.name.endsWith('.pyc')) continue + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...await filesIn(path)) + else files.push(path) + } + return files.sort() +} + +async function manifest () { + const result = new Map() + for (const path of await filesIn(generatedRoot)) { + const content = await readFile(path) + result.set(relative(generatedRoot, path), createHash('sha256').update(content).digest('hex')) + } + return result +} + +const before = await manifest() +const generation = spawnSync('pnpm', ['run', 'codegen'], { + cwd: packageRoot, + stdio: 'inherit', +}) +if (generation.error) throw generation.error +if (generation.status !== 0) process.exit(generation.status ?? 1) + +const after = await manifest() +const changed = [...new Set([...before.keys(), ...after.keys()])] + .filter(path => before.get(path) !== after.get(path)) + +if (changed.length > 0) { + console.error(`Generated files changed: ${changed.join(', ')}`) + process.exitCode = 1 +} else { + console.log('Generated files match the vendored OpenAPI spec.') +} diff --git a/python/src/hackmd_api/__init__.py b/python/src/hackmd_api/__init__.py new file mode 100644 index 0000000..c1b6963 --- /dev/null +++ b/python/src/hackmd_api/__init__.py @@ -0,0 +1,5 @@ +"""Experimental HackMD API client.""" + +from .generated import Sdk + +__all__ = ["Sdk"] diff --git a/python/src/hackmd_api/generated/__init__.py b/python/src/hackmd_api/generated/__init__.py new file mode 100644 index 0000000..920a42a --- /dev/null +++ b/python/src/hackmd_api/generated/__init__.py @@ -0,0 +1,5 @@ +# This file is auto-generated by @hey-api/openapi-python + +from .sdk_gen import Sdk + +__all__ = ["Sdk"] diff --git a/python/src/hackmd_api/generated/client/__init__.py b/python/src/hackmd_api/generated/client/__init__.py new file mode 100644 index 0000000..2575421 --- /dev/null +++ b/python/src/hackmd_api/generated/client/__init__.py @@ -0,0 +1,5 @@ +# This file is auto-generated by @hey-api/openapi-python + +from .client_gen import Client, build_client_params, create_client + +__all__ = ["Client", "build_client_params", "create_client"] diff --git a/python/src/hackmd_api/generated/client/client_gen.py b/python/src/hackmd_api/generated/client/client_gen.py new file mode 100644 index 0000000..1939fbd --- /dev/null +++ b/python/src/hackmd_api/generated/client/client_gen.py @@ -0,0 +1,150 @@ +# This file is auto-generated by @hey-api/openapi-python + +from typing import Any, Optional +from urllib.parse import quote + +import httpx + +EXTRA_PREFIXES_MAP = { + "$body_": "json", + "$headers_": "headers", + "$path_": "path", + "$query_": "params", +} + + +def build_client_params(fields: list[dict[str, Any]], /, **kwargs) -> dict[str, Any]: + """Build client parameters from flat keyword arguments. + + Args: + fields: List of field configurations with 'in', 'key', and optional 'map'. + **kwargs: Flat parameters passed to the SDK method. + + Returns: + Dict suitable for httpx client methods: {params: {...}, headers: {...}, json: Any} + """ + result: dict[str, Any] = {} + + key_map = {} + for field in fields: + key = field.get("key") + if key: + key_map[key] = { + "in": field.get("in"), + "map": field.get("map", key), + } + + for key, value in kwargs.items(): + if value is None: + continue + + field = key_map.get(key) + + if field: + in_slot = field["in"] + map_key = field["map"] + slot = {"body": "json", "query": "params"}.get(in_slot, in_slot) + + if in_slot == "body" and map_key == "body": + result[slot] = value + else: + if slot not in result: + result[slot] = {} + result[slot][map_key] = value + else: + for prefix, slot in EXTRA_PREFIXES_MAP.items(): + if key.startswith(prefix): + actual_key = key[len(prefix) :] + if slot not in result: + result[slot] = {} + result[slot][actual_key] = value + break + else: + if "params" not in result: + result["params"] = {} + result["params"][key] = value + + for slot in ("headers", "params", "path"): + if slot in result and not result[slot]: + del result[slot] + + return result + + +class BaseClient: + """Base HTTP client using httpx that SDK classes extend.""" + + def __init__(self, client: Optional[httpx.Client] = None, base_url: Optional[str] = None, **kwargs): + if client is not None: + self._client = client + else: + self._client = httpx.Client(base_url=base_url or "", **kwargs) + + @property + def client(self) -> httpx.Client: + """Get the httpx client instance.""" + return self._client + + def request(self, method: str, url: str, **kwargs) -> httpx.Response: + """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, + **kwargs, + ) -> httpx.Response: + """Make an HTTP request.""" + request_options = dict(options or {}) + 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) + + 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) + + def post(self, url: str, **kwargs) -> httpx.Response: + """Make a POST request.""" + return self._client.post(url, **kwargs) + + def put(self, url: str, **kwargs) -> httpx.Response: + """Make a PUT request.""" + return self._client.put(url, **kwargs) + + def patch(self, url: str, **kwargs) -> httpx.Response: + """Make a PATCH request.""" + return self._client.patch(url, **kwargs) + + def delete(self, url: str, **kwargs) -> httpx.Response: + """Make a DELETE request.""" + return self._client.delete(url, **kwargs) + + def close(self): + """Close the client.""" + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +class Client(BaseClient): + """HTTP client using httpx (alias for BaseClient).""" + + pass + + +def create_client(base_url: Optional[str] = None, **kwargs) -> Client: + """Create a new HTTP client instance.""" + return Client(base_url=base_url, **kwargs) diff --git a/python/src/hackmd_api/generated/pydantic_gen.py b/python/src/hackmd_api/generated/pydantic_gen.py new file mode 100644 index 0000000..54be87e --- /dev/null +++ b/python/src/hackmd_api/generated/pydantic_gen.py @@ -0,0 +1,1586 @@ +# This file is auto-generated by @hey-api/openapi-python + +from enum import Enum +from typing import Annotated, Any, Optional, TypeAlias, Union +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, RootModel + + +class ApiWebhookScopeType(str, Enum): + WORKSPACE = "workspace" + + +class ApiWebhookScope_(BaseModel): + type: ApiWebhookScopeType + + +class ApiWebhookScopeType_(str, Enum): + FOLDER = "folder" + + +class ApiWebhookScope_2(BaseModel): + folder_id: str = Field(..., alias="folderId") + type: ApiWebhookScopeType_ + + +class ApiWebhookScope(RootModel[Union[ApiWebhookScope_, ApiWebhookScope_2]]): + root: Union[ApiWebhookScope_, ApiWebhookScope_2] + + +class ApiWebhookEventDeliveryMode(str, Enum): + ALL_SUPPORTED_EVENTS = "all_supported_events" + + +class ApiWebhookDeliveryPolicyReason(str, Enum): + UNSAFE_DESTINATION = "unsafe_destination" + SUSPICIOUS_PATTERN = "suspicious_pattern" + POLICY_VIOLATION = "policy_violation" + + +class ApiWebhookDeliveryPolicyStatus(str, Enum): + NORMAL = "normal" + SUPPRESSED = "suppressed" + + +class ApiWebhookConfiguredStatusReason(str, Enum): + SCOPE_DELETED = "scope_deleted" + + +class ApiWebhookConfiguredStatus(str, Enum): + ACTIVE = "active" + DISABLED = "disabled" + + +class ApiWebhook(BaseModel): + updated_at: float = Field(..., alias="updatedAt") + created_at: float = Field(..., alias="createdAt") + event_delivery_mode: ApiWebhookEventDeliveryMode = Field(..., alias="eventDeliveryMode") + delivery_policy_reason: ApiWebhookDeliveryPolicyReason = Field(..., alias="deliveryPolicyReason") + delivery_policy_status: ApiWebhookDeliveryPolicyStatus = Field(..., alias="deliveryPolicyStatus") + configured_status_reason: ApiWebhookConfiguredStatusReason = Field(..., alias="configuredStatusReason") + configured_status: ApiWebhookConfiguredStatus = Field(..., alias="configuredStatus") + scope: ApiWebhookScope + url: str + name: str + id: str + + +class WebhookApiErrorResponseError(BaseModel): + message: str + code: str + + +class WebhookApiErrorResponse(BaseModel): + error: WebhookApiErrorResponseError + + +class ApiValidationFieldError(BaseModel): + model_config = ConfigDict(extra="forbid") + message: str + value: Optional[Any] = None + + +class RecordStringApiValidationFieldError(RootModel[dict[str, ApiValidationFieldError]]): + """Construct a type with a set of properties K of type T""" + + root: dict[str, ApiValidationFieldError] + + +class ApiValidationErrorMessage(str, Enum): + VALIDATION_FAILED = "Validation Failed" + + +class ApiValidationError(BaseModel): + model_config = ConfigDict(extra="forbid") + message: ApiValidationErrorMessage + details: RecordStringApiValidationFieldError + + +class CreateWebhookDataScopeType(str, Enum): + WORKSPACE = "workspace" + + +class CreateWebhookDataScope(BaseModel): + type: CreateWebhookDataScopeType + + +class CreateWebhookDataScopeType_(str, Enum): + FOLDER = "folder" + + +class CreateWebhookDataScope_(BaseModel): + folder_id: str = Field(..., alias="folderId") + type: CreateWebhookDataScopeType_ + + +class CreateWebhookData(BaseModel): + scope: Union[CreateWebhookDataScope, CreateWebhookDataScope_] + url: str + name: Optional[Annotated[str, Field(max_length=255)]] = None + + +class CreateApiWebhookBody_(BaseModel): + active: Optional[bool] = None + + +CreateApiWebhookBody: TypeAlias = Any + + +class UpdateWebhookData(BaseModel): + active: Optional[bool] = None + url: Optional[str] = None + name: Optional[Annotated[str, Field(max_length=255)]] = None + + +class UpdateApiWebhookBody(RootModel[UpdateWebhookData]): + root: UpdateWebhookData + + +class WebhookDeliveryStatusCategory(str, Enum): + DELIVERED = "delivered" + DELIVERY_ISSUE = "delivery_issue" + NOT_DELIVERED = "not_delivered" + + +class ApiWebhookDeliveryStatus(str, Enum): + SUCCESS = "success" + FAILED = "failed" + SKIPPED = "skipped" + + +class ApiWebhookDeliveryAttemptKind(str, Enum): + EVENT = "event" + PING = "ping" + + +class ApiWebhookDelivery(BaseModel): + delivered_at: Optional[float] = Field(default=None, alias="deliveredAt") + created_at: float = Field(..., alias="createdAt") + latency_ms: Optional[float] = Field(default=None, alias="latencyMs") + error_message: Optional[str] = Field(default=None, alias="errorMessage") + error_category: Optional[str] = Field(default=None, alias="errorCategory") + http_status: Optional[float] = Field(default=None, alias="httpStatus") + status_category: WebhookDeliveryStatusCategory = Field(..., alias="statusCategory") + status: ApiWebhookDeliveryStatus + attempt_kind: ApiWebhookDeliveryAttemptKind = Field(..., alias="attemptKind") + event_type: str = Field(..., alias="eventType") + id: str + + +class PaginatedQuery(BaseModel): + model_config = ConfigDict(extra="forbid") + limit: float + page: float + + +class PaginatedResponseApiWebhookDeliveryMeta(BaseModel): + total_pages: Optional[float] = Field(default=None, alias="totalPages") + total: Optional[float] = None + + +class PaginatedResponseApiWebhookDelivery(BaseModel): + model_config = ConfigDict(extra="forbid") + data: list[ApiWebhookDelivery] + meta: Any + + +ApiWebhookPage: TypeAlias = int + + +ApiWebhookLimit: TypeAlias = int + + +class SimpleUserProfile(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + name: str + user_path: str = Field(..., alias="userPath") + photo: str + biography: Optional[str] = None + + +class NotePublishType(str, Enum): + EDIT = "edit" + VIEW = "view" + SLIDE = "slide" + BOOK = "book" + + +class NotePermissionRole(str, Enum): + OWNER = "owner" + SIGNED_IN = "signed_in" + GUEST = "guest" + + +class FolderPath(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: str + name: str + icon: Optional[str] = None + color: Optional[str] = None + parent_id: Optional[str] = Field(default=None, alias="parentId") + client_id: str = Field(..., alias="clientId") + + +class NoteType(BaseModel): + folder_paths: Optional[list[FolderPath]] = Field(default=None, alias="folderPaths") + write_permission: NotePermissionRole = Field(..., alias="writePermission") + read_permission: NotePermissionRole = Field(..., alias="readPermission") + short_id: str = Field(..., alias="shortId") + publish_link: str = Field(..., alias="publishLink") + permalink: Optional[str] = None + team_path: Optional[str] = Field(default=None, alias="teamPath") + user_path: Optional[str] = Field(default=None, alias="userPath") + published_at: Optional[float] = Field(default=None, alias="publishedAt") + publish_type: NotePublishType = Field(..., alias="publishType") + last_change_user: Optional[SimpleUserProfile] = Field(default=None, alias="lastChangeUser") + tags_updated_at: Optional[float] = Field(default=None, alias="tagsUpdatedAt") + title_updated_at: Optional[float] = Field(default=None, alias="titleUpdatedAt") + created_at: float = Field(..., alias="createdAt") + last_changed_at: float = Field(..., alias="lastChangedAt") + description: str + tags: list[str] + title: str + id: str + + +class SingleNote(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + folder_paths: Optional[list[FolderPath]] = Field(default=None, alias="folderPaths") + write_permission: NotePermissionRole = Field(..., alias="writePermission") + read_permission: NotePermissionRole = Field(..., alias="readPermission") + short_id: str = Field(..., alias="shortId") + publish_link: str = Field(..., alias="publishLink") + permalink: Optional[str] = None + team_path: Optional[str] = Field(default=None, alias="teamPath") + user_path: Optional[str] = Field(default=None, alias="userPath") + published_at: Optional[float] = Field(default=None, alias="publishedAt") + publish_type: NotePublishType = Field(..., alias="publishType") + last_change_user: Optional[SimpleUserProfile] = Field(default=None, alias="lastChangeUser") + tags_updated_at: Optional[float] = Field(default=None, alias="tagsUpdatedAt") + title_updated_at: Optional[float] = Field(default=None, alias="titleUpdatedAt") + created_at: float = Field(..., alias="createdAt") + last_changed_at: float = Field(..., alias="lastChangedAt") + description: str + tags: list[str] + title: str + id: str + content: str + + +class ApiErrorResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + error: str + + +class CreateNoteMultiStatusResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + note: SingleNote + error: str + + +class ApiRequestErrorResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + message: str + + +class ApiBadRequestResponse(RootModel[Union[ApiErrorResponse, ApiRequestErrorResponse]]): + root: Union[ApiErrorResponse, ApiRequestErrorResponse] + + +class CommentPermissionType(str, Enum): + DISABLED = "disabled" + FORBIDDEN = "forbidden" + OWNERS = "owners" + SIGNED_IN_USERS = "signed_in_users" + EVERYONE = "everyone" + + +class SuggestEditPermissionType(str, Enum): + DISABLED = "disabled" + FORBIDDEN = "forbidden" + OWNERS = "owners" + SIGNED_IN_USERS = "signed_in_users" + + +class ApiNoteFeaturesEmojiReply(str, Enum): + DISABLED = "disabled" + SIGNED_IN_USERS = "signed_in_users" + + +class ApiNoteFeaturesCitation(str, Enum): + DISABLED = "disabled" + SIGNED_IN_USERS = "signed_in_users" + + +class ApiNoteFeatures(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + emoji_reply: Optional[ApiNoteFeaturesEmojiReply] = Field(default=None, alias="emoji-reply") + citation: Optional[ApiNoteFeaturesCitation] = None + + +class FolderName(RootModel[str]): + """Folder name.""" + + root: str = Field(..., description="Folder name.") + + +class FolderIconCodepoint(RootModel[str]): + """Folder icon as an emoji unified codepoint string. + Examples: `1F525` (🔥), `2764-FE0F` (❤️). + """ + + root: str = Field(..., description="""Folder icon as an emoji unified codepoint string. +Examples: `1F525` (🔥), `2764-FE0F` (❤️).""") + + +class FolderColorHex(RootModel[str]): + """Folder display color in hexadecimal format. + Accepts `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA`. + """ + + root: str = Field(..., description="""Folder display color in hexadecimal format. +Accepts `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA`.""") + + +class ApiFolder(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: str + name: FolderName + description: Optional[str] = None + icon: Optional[FolderIconCodepoint] = None + color: Optional[FolderColorHex] = None + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + created_at: float = Field(..., alias="createdAt") + updated_at: float = Field(..., alias="updatedAt") + + +class ApiFolderOrder(RootModel[dict[str, list[str]]]): + """Per-user display order of folders: maps each parent folder id (or `root`) to + an ordered list of child folder ids. Keys and values use the same UUIDs as + {@link ApiFolder.id} (not Yjs clientIds stored internally in preferences). + """ + + root: dict[str, list[str]] + + +class UpdateFolderOrderBody(BaseModel): + model_config = ConfigDict(extra="forbid") + order: ApiFolderOrder + + +class CreateUserFolderBody(BaseModel): + color: Optional[FolderColorHex] = None + icon: Optional[FolderIconCodepoint] = None + description: Optional[str] = None + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + name: Optional[FolderName] = None + + +class UpdateUserFolderBody(BaseModel): + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + color: Optional[FolderColorHex] = None + icon: Optional[FolderIconCodepoint] = None + description: Optional[str] = None + name: Optional[FolderName] = None + + +class ApiTrashNote(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: str + title: str + tags: list[str] + deleted_at: float = Field(..., alias="deletedAt") + owner_id: str = Field(..., alias="ownerId") + team_id: Optional[str] = Field(default=None, alias="teamId") + short_id: str = Field(..., alias="shortId") + publish_type: str = Field(..., alias="publishType") + permanent_deleted_at: Optional[float] = Field(default=None, alias="permanentDeletedAt") + + +class BatchRestoreTrashBody(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + note_ids: list[str] = Field(..., alias="noteIds") + + +class BatchOperationError(str, Enum): + NOT_FOUND = "NOT_FOUND" + PERMISSION_DENIED = "PERMISSION_DENIED" + INTERNAL_ERROR = "INTERNAL_ERROR" + + +class TrashBatchOperationResponseValueStatus(str, Enum): + SUCCESS = "success" + FAILURE = "failure" + + +class TrashBatchOperationResponseValue(BaseModel): + reason: Optional[BatchOperationError] = None + status: TrashBatchOperationResponseValueStatus + + +class TrashBatchOperationResponse(RootModel[dict[str, TrashBatchOperationResponseValue]]): + root: dict[str, TrashBatchOperationResponseValue] + + +class TeamVisibilityType(str, Enum): + PUBLIC = "public" + PRIVATE = "private" + + +class Team(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: str + owner_id: str = Field(..., alias="ownerId") + name: str + logo: str + path: str + description: Optional[str] = None + visibility: TeamVisibilityType + upgraded: bool + created_at: float = Field(..., alias="createdAt") + + +class CreateTeamFolderBody(BaseModel): + color: Optional[FolderColorHex] = None + icon: Optional[FolderIconCodepoint] = None + description: Optional[str] = None + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + name: Optional[FolderName] = None + + +class UpdateTeamFolderBody(BaseModel): + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + color: Optional[FolderColorHex] = None + icon: Optional[FolderIconCodepoint] = None + description: Optional[str] = None + name: Optional[FolderName] = None + + +class NoteVersionUser(BaseModel): + model_config = ConfigDict(extra="forbid") + user_id: str + display_name: str + photo: Optional[str] = None + + +class NoteVersionMetadataNameSource(str, Enum): + USER = "user" + SYSTEM = "system" + + +class NoteVersionMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str + name: str + name_source: NoteVersionMetadataNameSource + description: Optional[str] = None + created_at: str + created_by: Optional[NoteVersionUser] = None + authorship: list[NoteVersionUser] + content_available: bool + + +class GetNoteVersionsMeta(BaseModel): + total_pages: float + total: float + limit: float + page: float + + +class GetNoteVersions(BaseModel): + model_config = ConfigDict(extra="forbid") + data: list[NoteVersionMetadata] + meta: GetNoteVersionsMeta + + +class ApiError(BaseModel): + model_config = ConfigDict(extra="forbid") + error_code: str + message: str + unavailable_ref: Optional[str] = None + + +class CompareNoteVersions(BaseModel): + model_config = ConfigDict(extra="forbid") + unified_diff: str + + +class NoteVersionNameSource(str, Enum): + USER = "user" + SYSTEM = "system" + + +class NoteVersion(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str + name: str + name_source: NoteVersionNameSource + description: Optional[str] = None + created_at: str + created_by: Optional[NoteVersionUser] = None + authorship: list[NoteVersionUser] + content_available: bool + content: Optional[str] = None + + +class NoContentChangeErrorErrorCode(str, Enum): + NO_CONTENT_CHANGE = "no_content_change" + + +class NoContentChangeErrorLatestNameSource(str, Enum): + SYSTEM = "system" + USER = "user" + + +class NoContentChangeError(BaseModel): + model_config = ConfigDict(extra="forbid") + error_code: NoContentChangeErrorErrorCode + message: str + latest_version_id: str + latest_name_source: NoContentChangeErrorLatestNameSource + + +class VersionAlreadyNamedErrorErrorCode(str, Enum): + VERSION_ALREADY_NAMED = "version_already_named" + + +class VersionAlreadyNamedError(BaseModel): + model_config = ConfigDict(extra="forbid") + error_code: VersionAlreadyNamedErrorErrorCode + message: str + version_id: str + name: str + + +class CreateNoteVersionConflictError(RootModel[Union[NoContentChangeError, VersionAlreadyNamedError]]): + root: Union[NoContentChangeError, VersionAlreadyNamedError] + + +class CreateNoteVersionBody(BaseModel): + model_config = ConfigDict(extra="forbid") + base: Optional[str] = Field(default="note_content", description="Saved content to name. Accepts note_content or version:{id}.") + name: Annotated[str, Field(min_length=1, max_length=80, pattern=r"\S")] + description: Optional[Annotated[str, Field(max_length=500)]] = None + + +class VersionNotNamedErrorErrorCode(str, Enum): + VERSION_NOT_NAMED = "version_not_named" + + +class VersionNotNamedError(BaseModel): + model_config = ConfigDict(extra="forbid") + error_code: VersionNotNamedErrorErrorCode + message: str + version_id: str + + +class UpdateNoteVersionBody_(BaseModel): + description: Optional[Annotated[str, Field(max_length=500)]] = None + name: Annotated[str, Field(min_length=1, max_length=80, pattern=r"\S")] + base: str = Field(..., description="Existing named version to update, formatted as version:{id}.") + + +class UpdateNoteVersionBody_2(BaseModel): + description: Annotated[str, Field(max_length=500)] + name: Optional[Annotated[str, Field(min_length=1, max_length=80, pattern=r"\S")]] = None + base: str = Field(..., description="Existing named version to update, formatted as version:{id}.") + + +class UpdateNoteVersionBody(RootModel[Union[UpdateNoteVersionBody_, UpdateNoteVersionBody_2]]): + root: Union[UpdateNoteVersionBody_, UpdateNoteVersionBody_2] + + +class NoteImageUploadResponseData(BaseModel): + link: str + + +class NoteImageUploadResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + data: NoteImageUploadResponseData + + +class ApiCommentStatus(str, Enum): + OPEN = "open" + HIDDEN = "hidden" + + +class ApiCommentStateReason(str, Enum): + RESOLVED = "resolved" + SPAM = "spam" + ABUSE = "abuse" + OFF_TOPIC = "off_topic" + + +class ApiCommentStateUserActorKind(str, Enum): + USER = "user" + + +class ApiCommentStateUserActor(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + kind: ApiCommentStateUserActorKind + user_id: UUID = Field(..., alias="userId") + display_name: str = Field(..., alias="displayName") + + +class ApiCommentStateDeletedUserActorKind(str, Enum): + DELETED_USER = "deleted_user" + + +class ApiCommentStateDeletedUserActor(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: ApiCommentStateDeletedUserActorKind + + +class ApiCommentStateActor(RootModel[Union[ApiCommentStateUserActor, ApiCommentStateDeletedUserActor]]): + root: Union[ApiCommentStateUserActor, ApiCommentStateDeletedUserActor] + + +class ApiCommentThreadState(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + status: ApiCommentStatus + reason: ApiCommentStateReason + acted_by: Optional[ApiCommentStateActor] = Field(default=None, alias="actedBy") + acted_at: Optional[float] = Field(default=None, alias="actedAt", description="Unix timestamp in milliseconds.") + source_comment_id: UUID = Field(..., alias="sourceCommentId") + + +class ApiCommentUserActorKind(str, Enum): + USER = "user" + + +class ApiCommentUserActor(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + kind: ApiCommentUserActorKind + user_id: UUID = Field(..., alias="userId") + display_name: str = Field(..., alias="displayName") + + +class ApiCommentGuestActorKind(str, Enum): + GUEST = "guest" + + +class ApiCommentGuestActor(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + kind: ApiCommentGuestActorKind + display_name: str = Field(..., alias="displayName") + + +class ApiCommentTeamActorKind(str, Enum): + TEAM = "team" + + +class ApiCommentTeamActor(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + kind: ApiCommentTeamActorKind + team_id: UUID = Field(..., alias="teamId") + display_name: str = Field(..., alias="displayName") + + +class ApiCommentDeletedUserActorKind(str, Enum): + DELETED_USER = "deleted_user" + + +class ApiCommentDeletedUserActor(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: ApiCommentDeletedUserActorKind + + +class ApiCommentActor(RootModel[Union[ApiCommentUserActor, ApiCommentGuestActor, ApiCommentTeamActor, ApiCommentDeletedUserActor]]): + root: Union[ApiCommentUserActor, ApiCommentGuestActor, ApiCommentTeamActor, ApiCommentDeletedUserActor] + + +class ApiCommentState(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + status: ApiCommentStatus + reason: ApiCommentStateReason + acted_by: Optional[ApiCommentStateActor] = Field(default=None, alias="actedBy") + acted_at: Optional[float] = Field(default=None, alias="actedAt", description="Unix timestamp in milliseconds.") + + +class ApiCommentAnchor(BaseModel): + model_config = ConfigDict(extra="forbid") + offset: Annotated[int, Field(ge=0)] + length: Annotated[int, Field(ge=0)] + + +class ApiCommentReactionShortcode(str, Enum): + VALUE_1 = "+1" + VALUE_1_ = "-1" + JOY = "joy" + TADA = "tada" + THINKING_FACE = "thinking_face" + HEART = "heart" + ROCKET = "rocket" + EYES = "eyes" + + +class ApiCommentReactionSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + shortcode: ApiCommentReactionShortcode + count: Annotated[int, Field(ge=1)] + + +class ApiCommentHeadContentType(str, Enum): + PLAIN_TEXT = "plain_text" + + +class ApiCommentHeadIsThreadHead(Enum): + TRUE = True + + +class ApiCommentHead(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: UUID + thread_id: UUID = Field(..., alias="threadId") + actor: ApiCommentActor + body: str + content_type: ApiCommentHeadContentType = Field(..., alias="contentType") + comment_state: ApiCommentState = Field(..., alias="commentState") + is_pinned: bool = Field(..., alias="isPinned") + anchor: Optional[ApiCommentAnchor] = None + current_anchor: Optional[ApiCommentAnchor] = Field(default=None, alias="currentAnchor") + excerpt: Optional[str] = None + reactions: list[ApiCommentReactionSummary] + created_at: float = Field(..., alias="createdAt", description="Unix timestamp in milliseconds.") + updated_at: float = Field(..., alias="updatedAt", description="Unix timestamp in milliseconds.") + is_thread_head: ApiCommentHeadIsThreadHead = Field(..., alias="isThreadHead") + reply_count: Annotated[int, Field(ge=0)] = Field(..., alias="replyCount") + thread_state: ApiCommentThreadState = Field(..., alias="threadState") + + +class ApiCommentListReplyContentType(str, Enum): + PLAIN_TEXT = "plain_text" + + +class ApiCommentListReplyIsThreadHead(Enum): + FALSE = False + + +class ApiCommentListReply(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: UUID + thread_id: UUID = Field(..., alias="threadId") + actor: ApiCommentActor + body: str + content_type: ApiCommentListReplyContentType = Field(..., alias="contentType") + comment_state: ApiCommentState = Field(..., alias="commentState") + is_pinned: bool = Field(..., alias="isPinned") + anchor: Optional[ApiCommentAnchor] = None + current_anchor: Optional[ApiCommentAnchor] = Field(default=None, alias="currentAnchor") + excerpt: Optional[str] = None + reactions: list[ApiCommentReactionSummary] + created_at: float = Field(..., alias="createdAt", description="Unix timestamp in milliseconds.") + updated_at: float = Field(..., alias="updatedAt", description="Unix timestamp in milliseconds.") + is_thread_head: ApiCommentListReplyIsThreadHead = Field(..., alias="isThreadHead") + + +class ApiComment(RootModel[Union[ApiCommentHead, ApiCommentListReply]]): + root: Union[ApiCommentHead, ApiCommentListReply] + + +class ApiCommentPagination(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + page: Annotated[int, Field(ge=1)] + limit: Annotated[int, Field(ge=1, le=100)] + has_next: bool = Field(..., alias="hasNext") + + +class GetNoteComments(BaseModel): + model_config = ConfigDict(extra="forbid") + comments: list[ApiComment] + pagination: ApiCommentPagination + + +class ApiCommentError(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + error_code: str = Field(..., alias="errorCode") + message: str + + +ApiCommentPage: TypeAlias = int + + +ApiCommentLimit: TypeAlias = int + + +class ApiCommentThreadId(RootModel[UUID]): + root: UUID + + +class ApiCommentDetailReplyContentType(str, Enum): + PLAIN_TEXT = "plain_text" + + +class ApiCommentDetailReplyIsThreadHead(Enum): + FALSE = False + + +class ApiCommentDetailReply(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: UUID + thread_id: UUID = Field(..., alias="threadId") + actor: ApiCommentActor + body: str + content_type: ApiCommentDetailReplyContentType = Field(..., alias="contentType") + comment_state: ApiCommentState = Field(..., alias="commentState") + is_pinned: bool = Field(..., alias="isPinned") + anchor: Optional[ApiCommentAnchor] = None + current_anchor: Optional[ApiCommentAnchor] = Field(default=None, alias="currentAnchor") + excerpt: Optional[str] = None + reactions: list[ApiCommentReactionSummary] + created_at: float = Field(..., alias="createdAt", description="Unix timestamp in milliseconds.") + updated_at: float = Field(..., alias="updatedAt", description="Unix timestamp in milliseconds.") + is_thread_head: ApiCommentDetailReplyIsThreadHead = Field(..., alias="isThreadHead") + thread_state: ApiCommentThreadState = Field(..., alias="threadState") + + +class ApiCommentDetail(RootModel[Union[ApiCommentHead, ApiCommentDetailReply]]): + root: Union[ApiCommentHead, ApiCommentDetailReply] + + +class ApiCommentId(RootModel[UUID]): + root: UUID + + +class ApiCommentResolutionCascade(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + thread_id: UUID = Field(..., alias="threadId") + + +class ApiCommentResolutionResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + comment: ApiCommentDetail + cascade: Optional[ApiCommentResolutionCascade] = None + + +class ApiCommentResolutionConflictError(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + error_code: str = Field(..., alias="errorCode") + message: str + comment_state: ApiCommentState = Field(..., alias="commentState") + + +class User(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + id: str + email: Optional[str] = None + name: str + user_path: str = Field(..., alias="userPath") + photo: str + teams: list[Team] + teams_: Optional[list[Team]] = Field(default=None, alias="Teams") + upgraded: bool + + +class Note(BaseModel): + folder_paths: Optional[list[FolderPath]] = Field(default=None, alias="folderPaths") + write_permission: NotePermissionRole = Field(..., alias="writePermission") + read_permission: NotePermissionRole = Field(..., alias="readPermission") + short_id: str = Field(..., alias="shortId") + publish_link: str = Field(..., alias="publishLink") + permalink: Optional[str] = None + team_path: Optional[str] = Field(default=None, alias="teamPath") + user_path: Optional[str] = Field(default=None, alias="userPath") + published_at: Optional[float] = Field(default=None, alias="publishedAt") + publish_type: NotePublishType = Field(..., alias="publishType") + last_change_user: Optional[SimpleUserProfile] = Field(default=None, alias="lastChangeUser") + tags_updated_at: Optional[float] = Field(default=None, alias="tagsUpdatedAt") + title_updated_at: Optional[float] = Field(default=None, alias="titleUpdatedAt") + created_at: float = Field(..., alias="createdAt") + last_changed_at: float = Field(..., alias="lastChangedAt") + description: str + tags: list[str] + title: str + id: str + + +class GetUserHistory(RootModel[list[Note]]): + root: list[Note] + + +class ListWebhooksResponse(RootModel[list[ApiWebhook]]): + """Ok""" + + root: list[ApiWebhook] = Field(..., description="Ok") + + +class CreateWebhookBody(RootModel[CreateApiWebhookBody]): + root: CreateApiWebhookBody + + +class CreateWebhook(BaseModel): + secret: str + + +CreateWebhookResponse: TypeAlias = Any + + +class DeleteWebhookPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class DeleteWebhookResponse(RootModel[None]): + """No content""" + + root: None + + +class GetWebhookPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class GetWebhookResponse(RootModel[ApiWebhook]): + """Ok""" + + root: ApiWebhook + + +class UpdateWebhookBody(RootModel[UpdateWebhookData]): + root: UpdateWebhookData + + +class UpdateWebhookPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class UpdateWebhookResponse(RootModel[ApiWebhook]): + """Ok""" + + root: ApiWebhook + + +class PingWebhookPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class PingWebhookResponse(RootModel[None]): + """No content""" + + root: None + + +class ListWebhookDeliveriesPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class ListWebhookDeliveriesQuery(BaseModel): + page: Optional[ApiWebhookPage] = None + limit: Optional[ApiWebhookLimit] = None + + +class ListWebhookDeliveriesResponse(RootModel[PaginatedResponseApiWebhookDelivery]): + """Ok""" + + root: PaginatedResponseApiWebhookDelivery + + +class ExportWebhookDeliveriesPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + + +class ExportWebhookDeliveriesResponse(RootModel[str]): + """Ok""" + + root: str = Field(..., description="Ok") + + +class GetWebhookDeliveryPath(BaseModel): + hook_id: str = Field(..., alias="hookId") + delivery_id: str = Field(..., alias="deliveryId") + + +class GetWebhookDeliveryResponse(RootModel[ApiWebhookDelivery]): + """Ok""" + + root: ApiWebhookDelivery + + +class ListTeamWebhooksPath(BaseModel): + teampath: str + + +class ListTeamWebhooksResponse(RootModel[list[ApiWebhook]]): + """Ok""" + + root: list[ApiWebhook] = Field(..., description="Ok") + + +class CreateTeamWebhookBody(RootModel[CreateApiWebhookBody]): + root: CreateApiWebhookBody + + +class CreateTeamWebhookPath(BaseModel): + teampath: str + + +class CreateTeamWebhook(BaseModel): + secret: str + + +CreateTeamWebhookResponse: TypeAlias = Any + + +class DeleteTeamWebhookPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class DeleteTeamWebhookResponse(RootModel[None]): + """No content""" + + root: None + + +class GetTeamWebhookPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class GetTeamWebhookResponse(RootModel[ApiWebhook]): + """Ok""" + + root: ApiWebhook + + +class UpdateTeamWebhookBody(RootModel[UpdateWebhookData]): + root: UpdateWebhookData + + +class UpdateTeamWebhookPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class UpdateTeamWebhookResponse(RootModel[ApiWebhook]): + """Ok""" + + root: ApiWebhook + + +class PingTeamWebhookPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class PingTeamWebhookResponse(RootModel[None]): + """No content""" + + root: None + + +class ListTeamWebhookDeliveriesPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class ListTeamWebhookDeliveriesQuery(BaseModel): + page: Optional[ApiWebhookPage] = None + limit: Optional[ApiWebhookLimit] = None + + +class ListTeamWebhookDeliveriesResponse(RootModel[PaginatedResponseApiWebhookDelivery]): + """Ok""" + + root: PaginatedResponseApiWebhookDelivery + + +class ExportTeamWebhookDeliveriesPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + + +class ExportTeamWebhookDeliveriesResponse(RootModel[str]): + """Ok""" + + root: str = Field(..., description="Ok") + + +class GetTeamWebhookDeliveryPath(BaseModel): + teampath: str + hook_id: str = Field(..., alias="hookId") + delivery_id: str = Field(..., alias="deliveryId") + + +class GetTeamWebhookDeliveryResponse(RootModel[ApiWebhookDelivery]): + """Ok""" + + root: ApiWebhookDelivery + + +class ListNotesResponse(RootModel[list[NoteType]]): + """Ok""" + + root: list[NoteType] = Field(..., description="Ok") + + +class CreateNote(BaseModel): + origin: Optional[str] = None + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + permalink: Optional[str] = None + note_features: Optional[ApiNoteFeatures] = Field(default=None, alias="noteFeatures") + suggest_edit_permission: Optional[SuggestEditPermissionType] = Field(default=None, alias="suggestEditPermission") + comment_permission: Optional[CommentPermissionType] = Field(default=None, alias="commentPermission") + write_permission: Optional[NotePermissionRole] = Field(default=None, alias="writePermission") + read_permission: Optional[NotePermissionRole] = Field(default=None, alias="readPermission") + content: Optional[str] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + title: Optional[str] = None + + +class CreateNoteBody(RootModel[Union[CreateNote, str]]): + """The note content or a JSON object with note properties.""" + + root: Union[CreateNote, str] = Field(..., description="The note content or a JSON object with note properties.") + + +class CreateNoteResponse(RootModel[Union[SingleNote, CreateNoteMultiStatusResponse]]): + root: Union[SingleNote, CreateNoteMultiStatusResponse] + + +class DeleteNotePath(BaseModel): + note_id: str = Field(..., alias="noteId", description="The ID of the note to delete.") + + +class DeleteNoteResponse(RootModel[None]): + """No content""" + + root: None + + +class GetNotePath(BaseModel): + note_id: str = Field(..., alias="noteId", description="The ID of the note to retrieve.") + + +class GetNoteResponse(RootModel[SingleNote]): + root: SingleNote + + +class UpdateNoteBody(BaseModel): + """The properties to update on the note.""" + + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + permalink: Optional[str] = None + write_permission: Optional[NotePermissionRole] = Field(default=None, alias="writePermission") + read_permission: Optional[NotePermissionRole] = Field(default=None, alias="readPermission") + content: Optional[str] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + title: Optional[str] = None + + +class UpdateNotePath(BaseModel): + note_id: str = Field(..., alias="noteId", description="The ID of the note to update.") + + +class ListFoldersResponse(RootModel[list[ApiFolder]]): + """Ok""" + + root: list[ApiFolder] = Field(..., description="Ok") + + +class CreateFolderBody(RootModel[CreateUserFolderBody]): + """The folder properties.""" + + root: CreateUserFolderBody + + +class CreateFolderResponse(RootModel[ApiFolder]): + root: ApiFolder + + +class GetFolderOrderResponse(RootModel[ApiFolderOrder]): + """Ok""" + + root: ApiFolderOrder + + +class UpdateFolderOrderBody_(RootModel[UpdateFolderOrderBody]): + root: UpdateFolderOrderBody + + +class UpdateFolderOrderResponse(RootModel[None]): + root: None + + +class DeleteFolderPath(BaseModel): + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to delete.") + + +class DeleteFolderResponse(RootModel[None]): + """No content""" + + root: None + + +class GetFolderPath(BaseModel): + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to retrieve.") + + +class GetFolderResponse(RootModel[ApiFolder]): + root: ApiFolder + + +class UpdateFolderBody(RootModel[UpdateUserFolderBody]): + """The properties to update.""" + + root: UpdateUserFolderBody + + +class UpdateFolderPath(BaseModel): + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to update.") + + +class ListTrashResponse(RootModel[list[ApiTrashNote]]): + """Ok""" + + root: list[ApiTrashNote] = Field(..., description="Ok") + + +class BatchRestoreBody(RootModel[BatchRestoreTrashBody]): + root: BatchRestoreTrashBody + + +class BatchRestoreResponse(RootModel[TrashBatchOperationResponse]): + root: TrashBatchOperationResponse + + +class RestoreNotePath(BaseModel): + note_id: str = Field(..., alias="noteId", description="Encoded note id of the trashed note.") + + +class RestoreNoteResponse(RootModel[None]): + root: None + + +class ListTeamTrashPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + + +class ListTeamTrashResponse(RootModel[list[ApiTrashNote]]): + root: list[ApiTrashNote] + + +class ListTeamsResponse(RootModel[list[Team]]): + """Ok""" + + root: list[Team] = Field(..., description="Ok") + + +class ListTeamNotesPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + + +class ListTeamNotesResponse(RootModel[list[NoteType]]): + """Ok""" + + root: list[NoteType] = Field(..., description="Ok") + + +class CreateTeamNote(BaseModel): + origin: Optional[str] = None + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + permalink: Optional[str] = None + note_features: Optional[ApiNoteFeatures] = Field(default=None, alias="noteFeatures") + suggest_edit_permission: Optional[SuggestEditPermissionType] = Field(default=None, alias="suggestEditPermission") + comment_permission: Optional[CommentPermissionType] = Field(default=None, alias="commentPermission") + write_permission: Optional[NotePermissionRole] = Field(default=None, alias="writePermission") + read_permission: Optional[NotePermissionRole] = Field(default=None, alias="readPermission") + content: Optional[str] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + title: Optional[str] = None + + +class CreateTeamNoteBody(RootModel[Union[CreateTeamNote, str]]): + """The note content (string) or a JSON object with note properties (title, content, permissions, etc.).""" + + root: Union[CreateTeamNote, str] = Field(..., description="The note content (string) or a JSON object with note properties (title, content, permissions, etc.).") + + +class CreateTeamNotePath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + + +class CreateTeamNoteResponse(RootModel[Union[SingleNote, CreateNoteMultiStatusResponse]]): + root: Union[SingleNote, CreateNoteMultiStatusResponse] + + +class DeleteTeamNotePath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + note_id: str = Field(..., alias="noteId", description="The ID of the note to delete.") + + +class DeleteTeamNoteResponse(RootModel[None]): + """No content""" + + root: None + + +class GetTeamNotePath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + note_id: str = Field(..., alias="noteId", description="The ID of the note to retrieve.") + + +class GetTeamNoteResponse(RootModel[SingleNote]): + root: SingleNote + + +class UpdateTeamNoteBody(BaseModel): + """The properties to update on the team note (e.g., content, permissions, permalink, parentFolderId).""" + + parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") + permalink: Optional[str] = None + write_permission: Optional[NotePermissionRole] = Field(default=None, alias="writePermission") + read_permission: Optional[NotePermissionRole] = Field(default=None, alias="readPermission") + content: Optional[str] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + title: Optional[str] = None + + +class UpdateTeamNotePath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + note_id: str = Field(..., alias="noteId", description="The ID of the note to update.") + + +class ListTeamFoldersPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + + +class ListTeamFoldersResponse(RootModel[list[ApiFolder]]): + """Ok""" + + root: list[ApiFolder] = Field(..., description="Ok") + + +class CreateTeamFolderBody_(RootModel[CreateTeamFolderBody]): + """The folder properties.""" + + root: CreateTeamFolderBody + + +class CreateTeamFolderPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + + +class CreateTeamFolderResponse(RootModel[ApiFolder]): + root: ApiFolder + + +class GetTeamFolderOrderPath(BaseModel): + teampath: str + + +class GetTeamFolderOrderResponse(RootModel[ApiFolderOrder]): + """Ok""" + + root: ApiFolderOrder + + +class UpdateTeamFolderOrderBody(RootModel[UpdateFolderOrderBody]): + root: UpdateFolderOrderBody + + +class UpdateTeamFolderOrderPath(BaseModel): + teampath: str + + +class UpdateTeamFolderOrderResponse(RootModel[None]): + root: None + + +class DeleteTeamFolderPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to delete.") + + +class DeleteTeamFolderResponse(RootModel[None]): + """No content""" + + root: None + + +class GetTeamFolderPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to retrieve.") + + +class GetTeamFolderResponse(RootModel[ApiFolder]): + root: ApiFolder + + +class UpdateTeamFolderBody_(RootModel[UpdateTeamFolderBody]): + """The properties to update.""" + + root: UpdateTeamFolderBody + + +class UpdateTeamFolderPath(BaseModel): + teampath: str = Field(..., description="The path identifier for the team.") + folder_id: str = Field(..., alias="folderId", description="The ID of the folder to update.") + + +class ListVersionsPath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class ListVersionsQuery(BaseModel): + named_only: Optional[bool] = None + q: Optional[str] = None + created_by: Optional[str] = None + created_after: Optional[str] = Field(default=None, description="Return versions created at or after this ISO-8601 datetime.") + created_before: Optional[str] = Field(default=None, description="Return versions created at or before this ISO-8601 datetime.") + page: Optional[float] = 1 + limit: Optional[float] = 50 + + +class ListVersionsResponse(RootModel[GetNoteVersions]): + """Ok""" + + root: GetNoteVersions + + +class UpdateVersionBody(RootModel[UpdateNoteVersionBody]): + root: UpdateNoteVersionBody + + +class UpdateVersionPath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class UpdateVersionResponse(RootModel[NoteVersionMetadata]): + """Ok""" + + root: NoteVersionMetadata + + +class CreateVersionBody(RootModel[CreateNoteVersionBody]): + root: CreateNoteVersionBody + + +class CreateVersionPath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class CreateVersionResponse(RootModel[NoteVersionMetadata]): + """Created""" + + root: NoteVersionMetadata + + +class CompareVersionsPath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class CompareVersionsQuery(BaseModel): + base: str = Field(..., description="Required comparison source. Use note_content or version:{id}.") + target: str = Field(..., description="Required comparison target. Use note_content or version:{id}.") + + +class CompareVersionsResponse(RootModel[CompareNoteVersions]): + """Ok""" + + root: CompareNoteVersions + + +class GetVersionPath(BaseModel): + note_id: str = Field(..., alias="noteId") + version_id: str = Field(..., alias="versionId") + + +class GetVersionResponse(RootModel[NoteVersion]): + """Ok""" + + root: NoteVersion + + +class UploadNoteImageBody(BaseModel): + image: bytes + + +class UploadNoteImagePath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class UploadNoteImageResponse(RootModel[NoteImageUploadResponse]): + root: NoteImageUploadResponse + + +class ListNoteCommentsPath(BaseModel): + note_id: str = Field(..., alias="noteId") + + +class ListNoteCommentsSort(str, Enum): + ASC = "asc" + DESC = "desc" + + +class ListNoteCommentsIsThreadHead(Enum): + TRUE = True + + +class ListNoteCommentsQuery(BaseModel): + page: Optional[ApiCommentPage] = None + limit: Optional[ApiCommentLimit] = None + sort: Optional[ListNoteCommentsSort] = None + thread_id: Optional[ApiCommentThreadId] = Field(default=None, alias="threadId") + comment_status: Optional[ApiCommentStatus] = Field(default=None, alias="commentStatus") + thread_status: Optional[ApiCommentStatus] = Field(default=None, alias="threadStatus") + is_thread_head: Optional[ListNoteCommentsIsThreadHead] = Field(default=None, alias="isThreadHead") + + +class ListNoteCommentsResponse(RootModel[GetNoteComments]): + """Ok""" + + root: GetNoteComments + + +class GetNoteCommentPath(BaseModel): + note_id: str = Field(..., alias="noteId") + comment_id: ApiCommentId = Field(..., alias="commentId") + + +class GetNoteCommentResponse(RootModel[ApiCommentDetail]): + """Ok""" + + root: ApiCommentDetail + + +class UnresolveNoteCommentPath(BaseModel): + note_id: str = Field(..., alias="noteId") + comment_id: ApiCommentId = Field(..., alias="commentId") + + +class UnresolveNoteCommentResponse(RootModel[ApiCommentResolutionResponse]): + """Ok""" + + root: ApiCommentResolutionResponse + + +class ResolveNoteCommentPath(BaseModel): + note_id: str = Field(..., alias="noteId") + comment_id: ApiCommentId = Field(..., alias="commentId") + + +class ResolveNoteCommentResponse(RootModel[ApiCommentResolutionResponse]): + """Ok""" + + root: ApiCommentResolutionResponse + + +class GetMeResponse(RootModel[User]): + """Ok""" + + root: User + + +class GetHistoryQuery(BaseModel): + limit: Optional[float] = Field(default=None, description="The maximum number of history items to return.") + + +class GetHistoryResponse(RootModel[GetUserHistory]): + """Ok""" + + root: GetUserHistory diff --git a/python/src/hackmd_api/generated/sdk_gen.py b/python/src/hackmd_api/generated/sdk_gen.py new file mode 100644 index 0000000..b6c0bdf --- /dev/null +++ b/python/src/hackmd_api/generated/sdk_gen.py @@ -0,0 +1,463 @@ +# This file is auto-generated by @hey-api/openapi-python + +from typing import Any, Optional + +from .client import build_client_params, Client +from .pydantic_gen import ApiCommentId, ApiCommentLimit, ApiCommentPage, ApiCommentStatus, ApiCommentThreadId, ApiWebhookLimit, ApiWebhookPage, BatchRestoreTrashBody, CreateApiWebhookBody, CreateNoteVersionBody, CreateTeamFolderBody, CreateUserFolderBody, NotePermissionRole, UpdateFolderOrderBody, UpdateNoteVersionBody, UpdateTeamFolderBody, UpdateUserFolderBody, UpdateWebhookData + + +class Sdk(Client): + def list_webhooks(self): + """List webhooks in the current user's workspace.""" + + return self.client.get("/webhooks") + + def create_webhook(self, create_api_webhook_body: CreateApiWebhookBody): + """Create a webhook in the current user's workspace. + The signing secret is returned only once and cannot be retrieved later. + """ + + params = build_client_params([{"in": "body", "key": "create_api_webhook_body", "map": "body"}], create_api_webhook_body=create_api_webhook_body) + return self.request_options("post", "/webhooks", params) + + def delete_webhook(self, hookId: str): + """Delete a webhook.""" + + params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) + return self.request_options("delete", "/webhooks/{hookId}", params) + + def get_webhook(self, hookId: str): + """Get a webhook in the current user's workspace.""" + + params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) + return self.request_options("get", "/webhooks/{hookId}", params) + + def update_webhook(self, hookId: str, update_webhook_data: UpdateWebhookData): + """Update a webhook. At least one of name, url, or active is required. + Scope and event selection cannot be changed. + """ + + params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "body", "key": "update_webhook_data", "map": "body"}], hookId=hookId, update_webhook_data=update_webhook_data) + return self.request_options("patch", "/webhooks/{hookId}", params) + + def ping_webhook(self, hookId: str): + """Send a test delivery. Read the result from the deliveries endpoint.""" + + params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) + return self.request_options("post", "/webhooks/{hookId}/ping", params) + + def list_webhook_deliveries( + self, + hookId: str, + page: Optional[ApiWebhookPage] = None, + limit: Optional[ApiWebhookLimit] = None, + ): + """List webhook deliveries within the workspace plan's retention window.""" + + params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], hookId=hookId, page=page, limit=limit) + return self.request_options("get", "/webhooks/{hookId}/deliveries", params) + + def export_webhook_deliveries(self, hookId: str): + """Download webhook deliveries as newline-delimited JSON.""" + + params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) + return self.request_options("get", "/webhooks/{hookId}/deliveries/export", params) + + def get_webhook_delivery(self, hookId: str, deliveryId: str): + """Get a webhook delivery within the workspace plan's retention window.""" + + params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "path", "key": "deliveryId"}], hookId=hookId, deliveryId=deliveryId) + return self.request_options("get", "/webhooks/{hookId}/deliveries/{deliveryId}", params) + + def list_team_webhooks(self, teampath: str): + """List webhooks in a team workspace.""" + + params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) + return self.request_options("get", "/teams/{teampath}/webhooks", params) + + def create_team_webhook(self, teampath: str, create_api_webhook_body: CreateApiWebhookBody): + """Create a webhook in a team workspace. + The signing secret is returned only once and cannot be retrieved later. + """ + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "create_api_webhook_body", "map": "body"}], teampath=teampath, create_api_webhook_body=create_api_webhook_body) + return self.request_options("post", "/teams/{teampath}/webhooks", params) + + def delete_team_webhook(self, teampath: str, hookId: str): + """Delete a team webhook.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) + return self.request_options("delete", "/teams/{teampath}/webhooks/{hookId}", params) + + def get_team_webhook(self, teampath: str, hookId: str): + """Get a webhook in a team workspace.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}", params) + + def update_team_webhook( + self, + teampath: str, + hookId: str, + update_webhook_data: UpdateWebhookData, + ): + """Update a team webhook. At least one of name, url, or active is required. + Scope and event selection cannot be changed. + """ + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "body", "key": "update_webhook_data", "map": "body"}], teampath=teampath, hookId=hookId, update_webhook_data=update_webhook_data) + return self.request_options("patch", "/teams/{teampath}/webhooks/{hookId}", params) + + def ping_team_webhook(self, teampath: str, hookId: str): + """Send a test delivery for a team webhook.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) + return self.request_options("post", "/teams/{teampath}/webhooks/{hookId}/ping", params) + + def list_team_webhook_deliveries( + self, + teampath: str, + hookId: str, + page: Optional[ApiWebhookPage] = None, + limit: Optional[ApiWebhookLimit] = None, + ): + """List team webhook deliveries within the plan's retention window.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], teampath=teampath, hookId=hookId, page=page, limit=limit) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries", params) + + def export_team_webhook_deliveries(self, teampath: str, hookId: str): + """Download team webhook deliveries as newline-delimited JSON.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/export", params) + + def get_team_webhook_delivery( + self, + teampath: str, + hookId: str, + deliveryId: str, + ): + """Get a team webhook delivery within the plan's retention window.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "path", "key": "deliveryId"}], teampath=teampath, hookId=hookId, deliveryId=deliveryId) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/{deliveryId}", params) + + def list_notes(self): + """List all notes for the current user""" + + return self.client.get("/notes") + + def create_note(self, body: Optional[Any] = None): + """Create a new note for the current user""" + + params = build_client_params([{"in": "body", "key": "body", "map": "body"}], body=body) + return self.request_options("post", "/notes", params) + + def delete_note(self, noteId: str): + """Delete a note for the current user""" + + params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) + return self.request_options("delete", "/notes/{noteId}", params) + + def get_note(self, noteId: str): + """Get a single note for the current user (or team note if accessible)""" + + params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) + return self.request_options("get", "/notes/{noteId}", params) + + def update_note( + self, + noteId: str, + parentFolderId: Optional[Any] = None, + permalink: Optional[str] = None, + writePermission: Optional[NotePermissionRole] = None, + readPermission: Optional[NotePermissionRole] = None, + content: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[list[str]] = None, + title: Optional[str] = None, + ): + """Update a note's content or permissions for the current user""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "parentFolderId"}, {"in": "body", "key": "permalink"}, {"in": "body", "key": "writePermission"}, {"in": "body", "key": "readPermission"}, {"in": "body", "key": "content"}, {"in": "body", "key": "description"}, {"in": "body", "key": "tags"}, {"in": "body", "key": "title"}], noteId=noteId, parentFolderId=parentFolderId, permalink=permalink, writePermission=writePermission, readPermission=readPermission, content=content, description=description, tags=tags, title=title) + return self.request_options("patch", "/notes/{noteId}", params) + + def list_folders(self): + """List all folders in the current user's workspace""" + + return self.client.get("/folders") + + def create_folder(self, create_user_folder_body: Optional[CreateUserFolderBody] = None): + """Create a new folder in the current user's workspace""" + + params = build_client_params([{"in": "body", "key": "create_user_folder_body", "map": "body"}], create_user_folder_body=create_user_folder_body) + return self.request_options("post", "/folders", params) + + def get_folder_order(self): + """Get your personal folder ordering for this workspace (parent folder id or `root` → ordered child folder ids). + Uses the same folder UUIDs as (not internal Yjs clientIds). + """ + + return self.client.get("/folders/folder-order") + + def update_folder_order(self, update_folder_order_body: Optional[UpdateFolderOrderBody] = None): + """Replace your personal folder ordering for this workspace.""" + + params = build_client_params([{"in": "body", "key": "update_folder_order_body", "map": "body"}], update_folder_order_body=update_folder_order_body) + return self.request_options("put", "/folders/folder-order", params) + + def delete_folder(self, folderId: str): + """Delete a folder in the current user's workspace""" + + params = build_client_params([{"in": "path", "key": "folderId"}], folderId=folderId) + return self.request_options("delete", "/folders/{folderId}", params) + + def get_folder(self, folderId: str): + """Get a single folder in the current user's workspace""" + + params = build_client_params([{"in": "path", "key": "folderId"}], folderId=folderId) + return self.request_options("get", "/folders/{folderId}", params) + + def update_folder(self, folderId: str, update_user_folder_body: Optional[UpdateUserFolderBody] = None): + """Update a folder in the current user's workspace""" + + params = build_client_params([{"in": "path", "key": "folderId"}, {"in": "body", "key": "update_user_folder_body", "map": "body"}], folderId=folderId, update_user_folder_body=update_user_folder_body) + return self.request_options("patch", "/folders/{folderId}", params) + + def list_trash(self): + """List trashed notes in your personal workspace (same data as the internal trash API).""" + + return self.client.get("/trash") + + def batch_restore(self, batch_restore_trash_body: BatchRestoreTrashBody): + """Restore multiple notes from trash in one request.""" + + params = build_client_params([{"in": "body", "key": "batch_restore_trash_body", "map": "body"}], batch_restore_trash_body=batch_restore_trash_body) + return self.request_options("put", "/trash/batch-restore", params) + + def restore_note(self, noteId: str): + """Restore a single note from trash.""" + + params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) + return self.request_options("put", "/trash/{noteId}/restore", params) + + def list_team_trash(self, teampath: str): + """List trashed notes in a team workspace (team admin only; same rules as the internal API).""" + + params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) + return self.request_options("get", "/teams/{teampath}/trash", params) + + def list_teams(self): + """List the teams for the current user""" + + return self.client.get("/teams") + + def list_team_notes(self, teampath: str): + """List all notes for a team""" + + params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) + return self.request_options("get", "/teams/{teampath}/notes", params) + + def create_team_note(self, teampath: str, body: Optional[Any] = None): + """Create a new note for a team""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "body", "map": "body"}], teampath=teampath, body=body) + return self.request_options("post", "/teams/{teampath}/notes", params) + + def delete_team_note(self, teampath: str, noteId: str): + """Delete a team note""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}], teampath=teampath, noteId=noteId) + return self.request_options("delete", "/teams/{teampath}/notes/{noteId}", params) + + def get_team_note(self, teampath: str, noteId: str): + """Get a single note for a team""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}], teampath=teampath, noteId=noteId) + return self.request_options("get", "/teams/{teampath}/notes/{noteId}", params) + + def update_team_note( + self, + teampath: str, + noteId: str, + parentFolderId: Optional[Any] = None, + permalink: Optional[str] = None, + writePermission: Optional[NotePermissionRole] = None, + readPermission: Optional[NotePermissionRole] = None, + content: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[list[str]] = None, + title: Optional[str] = None, + ): + """Update a team note""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}, {"in": "body", "key": "parentFolderId"}, {"in": "body", "key": "permalink"}, {"in": "body", "key": "writePermission"}, {"in": "body", "key": "readPermission"}, {"in": "body", "key": "content"}, {"in": "body", "key": "description"}, {"in": "body", "key": "tags"}, {"in": "body", "key": "title"}], teampath=teampath, noteId=noteId, parentFolderId=parentFolderId, permalink=permalink, writePermission=writePermission, readPermission=readPermission, content=content, description=description, tags=tags, title=title) + return self.request_options("patch", "/teams/{teampath}/notes/{noteId}", params) + + def list_team_folders(self, teampath: str): + """List all folders in a team workspace""" + + params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) + return self.request_options("get", "/teams/{teampath}/folders", params) + + def create_team_folder(self, teampath: str, create_team_folder_body: Optional[CreateTeamFolderBody] = None): + """Create a new folder in a team workspace""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "create_team_folder_body", "map": "body"}], teampath=teampath, create_team_folder_body=create_team_folder_body) + return self.request_options("post", "/teams/{teampath}/folders", params) + + def get_team_folder_order(self, teampath: str): + """Get your personal folder ordering for this team workspace (parent folder id or `root` → ordered child folder ids). + Uses the same folder UUIDs as (not internal Yjs clientIds). + """ + + params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) + return self.request_options("get", "/teams/{teampath}/folders/folder-order", params) + + def update_team_folder_order(self, teampath: str, update_folder_order_body: Optional[UpdateFolderOrderBody] = None): + """Replace your personal folder ordering for this team workspace.""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "update_folder_order_body", "map": "body"}], teampath=teampath, update_folder_order_body=update_folder_order_body) + return self.request_options("put", "/teams/{teampath}/folders/folder-order", params) + + def delete_team_folder(self, teampath: str, folderId: str): + """Delete a folder in a team workspace""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}], teampath=teampath, folderId=folderId) + return self.request_options("delete", "/teams/{teampath}/folders/{folderId}", params) + + def get_team_folder(self, teampath: str, folderId: str): + """Get a single folder in a team workspace""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}], teampath=teampath, folderId=folderId) + return self.request_options("get", "/teams/{teampath}/folders/{folderId}", params) + + def update_team_folder( + self, + teampath: str, + folderId: str, + update_team_folder_body: Optional[UpdateTeamFolderBody] = None, + ): + """Update a folder in a team workspace""" + + params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}, {"in": "body", "key": "update_team_folder_body", "map": "body"}], teampath=teampath, folderId=folderId, update_team_folder_body=update_team_folder_body) + return self.request_options("patch", "/teams/{teampath}/folders/{folderId}", params) + + def list_versions( + self, + noteId: str, + named_only: Optional[bool] = None, + q: Optional[str] = None, + created_by: Optional[str] = None, + created_after: Optional[str] = None, + created_before: Optional[str] = None, + page: Optional[float] = None, + limit: Optional[float] = None, + ): + """List note versions + + List saved versions for a note. + """ + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "named_only"}, {"in": "query", "key": "q"}, {"in": "query", "key": "created_by"}, {"in": "query", "key": "created_after"}, {"in": "query", "key": "created_before"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], noteId=noteId, named_only=named_only, q=q, created_by=created_by, created_after=created_after, created_before=created_before, page=page, limit=limit) + return self.request_options("get", "/notes/{noteId}/versions", params) + + def update_version(self, noteId: str, update_note_version_body: UpdateNoteVersionBody): + """Update named version metadata + + Update an existing named version's metadata with `PATCH /notes/{noteId}/versions` + and a `base` body field formatted as `version:{id}`. The item path + `/notes/{noteId}/versions/{versionId}` does not support PATCH. + """ + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "update_note_version_body", "map": "body"}], noteId=noteId, update_note_version_body=update_note_version_body) + return self.request_options("patch", "/notes/{noteId}/versions", params) + + def create_version(self, noteId: str, create_note_version_body: CreateNoteVersionBody): + """Create a named note version + + Create a named version from live note content or an existing saved version. When + live note content has changed, this creates and names a new saved version. When + live content matches the latest system version, that version is named in place and + returned with 201. When it matches the latest user-named version, the response is + 409 `no_content_change`. Naming an already named `version:{id}` returns 409 + `version_already_named`. + """ + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "create_note_version_body", "map": "body"}], noteId=noteId, create_note_version_body=create_note_version_body) + return self.request_options("post", "/notes/{noteId}/versions", params) + + def compare_versions( + self, + noteId: str, + base: str, + target: str, + ): + """Compare note versions + + Compare two saved versions or a saved version with live note content. + """ + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "base"}, {"in": "query", "key": "target"}], noteId=noteId, base=base, target=target) + return self.request_options("get", "/notes/{noteId}/versions/compare", params) + + def get_version(self, noteId: str, versionId: str): + """Get a note version + + Get one saved version with reconstructed content when available. + """ + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "versionId"}], noteId=noteId, versionId=versionId) + return self.request_options("get", "/notes/{noteId}/versions/{versionId}", params) + + def upload_note_image(self, noteId: str, image: str): + """Upload an image for a note.""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "image"}], noteId=noteId, image=image) + return self.request_options("post", "/notes/{noteId}/images", params) + + def list_note_comments( + self, + noteId: str, + page: Optional[ApiCommentPage] = None, + limit: Optional[ApiCommentLimit] = None, + sort: Optional[Any] = None, + threadId: Optional[ApiCommentThreadId] = None, + commentStatus: Optional[ApiCommentStatus] = None, + threadStatus: Optional[ApiCommentStatus] = None, + isThreadHead: Optional[Any] = None, + ): + """List comments and replies visible on a note.""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}, {"in": "query", "key": "sort"}, {"in": "query", "key": "threadId"}, {"in": "query", "key": "commentStatus"}, {"in": "query", "key": "threadStatus"}, {"in": "query", "key": "isThreadHead"}], noteId=noteId, page=page, limit=limit, sort=sort, threadId=threadId, commentStatus=commentStatus, threadStatus=threadStatus, isThreadHead=isThreadHead) + return self.request_options("get", "/notes/{noteId}/comments", params) + + def get_note_comment(self, noteId: str, commentId: ApiCommentId): + """Get one comment or reply from a note.""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) + return self.request_options("get", "/notes/{noteId}/comments/{commentId}", params) + + def unresolve_note_comment(self, noteId: str, commentId: ApiCommentId): + """Unresolve a comment or thread.""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) + return self.request_options("delete", "/notes/{noteId}/comments/{commentId}/resolution", params) + + def resolve_note_comment(self, noteId: str, commentId: ApiCommentId): + """Resolve a comment or thread.""" + + params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) + return self.request_options("put", "/notes/{noteId}/comments/{commentId}/resolution", params) + + def get_me(self): + """Get the current user's profile""" + + return self.client.get("/me") + + def get_history(self, limit: Optional[float] = None): + """Get note history for the current user""" + + params = build_client_params([{"in": "query", "key": "limit"}], limit=limit) + return self.request_options("get", "/history", params) From 748ff7a51e67eabf39c3470bc366f1ca17eb68a9 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Sun, 27 Sep 2026 22:12:38 +0800 Subject: [PATCH 3/7] test: verify generated Python API client requests --- python/README.md | 5 +- python/package.json | 3 +- python/pyproject.toml | 8 ++ python/tests/smoke.py | 117 +++++++++++++++++++ python/uv.lock | 262 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 python/pyproject.toml create mode 100644 python/tests/smoke.py create mode 100644 python/uv.lock diff --git a/python/README.md b/python/README.md index 846f711..59794a8 100644 --- a/python/README.md +++ b/python/README.md @@ -2,13 +2,14 @@ This is a code generation experiment, not a supported or published Python API client. It reads the same vendored OpenAPI spec as the Node.js client and uses flat parameters. Generated files in `src/hackmd_api/generated/` belong in version control and must not be edited by hand. -Requires Node.js 22.18+, pnpm 10.33.2, and Python 3.10+. From this directory: +Requires Node.js 22.18+, pnpm 10.33.2, Python 3.10+, and [uv](https://docs.astral.sh/uv/). From this directory: ```sh pnpm install --frozen-lockfile pnpm codegen pnpm check:generated pnpm check +pnpm test ``` The pinned generator includes a temporary [pnpm patch](./patches/README.md) adapting two pending upstream fixes. Use the commands above rather than `npx`, which bypasses the patch. @@ -17,4 +18,6 @@ Like the Node.js client, `codegen` generates sources and `check:generated` check `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. +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. + 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. diff --git a/python/package.json b/python/package.json index fabd775..9e33ff5 100644 --- a/python/package.json +++ b/python/package.json @@ -6,7 +6,8 @@ "scripts": { "codegen": "openapi-python", "check:generated": "node scripts/check-generated.mjs", - "check": "python3 -m compileall -q src/hackmd_api" + "check": "python3 -m compileall -q src/hackmd_api", + "test": "uv run --frozen python tests/smoke.py" }, "devDependencies": { "@hey-api/openapi-python": "0.0.24" diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..9af2e4e --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "hackmd-python-codegen-experiment" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = ["httpx==0.28.1", "pydantic==2.12.5"] + +[tool.uv] +package = false diff --git a/python/tests/smoke.py b/python/tests/smoke.py new file mode 100644 index 0000000..51f78dc --- /dev/null +++ b/python/tests/smoke.py @@ -0,0 +1,117 @@ +"""Offline checks against regenerated code; no real token or network requests.""" + +import json +from pathlib import Path +import sys +import unittest + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from hackmd_api import Sdk +from hackmd_api.generated import pydantic_gen as models + + +class GeneratedClientSmokeTest(unittest.TestCase): + def setUp(self): + self.requests = [] + self.response = httpx.Response(200, json={"content": "hello"}) + + def handle(request): + self.requests.append(request) + return self.response + + self.client = httpx.Client( + base_url="https://example.test/custom/v1", + headers={"Authorization": "Bearer test-token"}, + transport=httpx.MockTransport(handle), + ) + self.addCleanup(self.client.close) + self.sdk = Sdk(client=self.client) + + def test_all_spec_operations_have_methods(self): + document = json.loads((ROOT / "../nodejs/spec/hackmd-openapi.json").read_text()) + methods = {"get", "post", "put", "patch", "delete", "head", "options", "trace"} + operations = [ + operation + for path in document["paths"].values() + for method, operation in path.items() + if method in methods + ] + sdk_methods = [ + name for name, value in vars(Sdk).items() + if not name.startswith("_") and callable(value) + ] + self.assertEqual(len(sdk_methods), len(operations)) + + def test_reaction_wire_values_are_unchanged(self): + reaction = models.ApiCommentReactionShortcode + self.assertEqual(reaction("+1").value, "+1") + self.assertEqual(reaction("-1").value, "-1") + self.assertIsNot(reaction("+1"), reaction("-1")) + + def test_note_path_auth_and_raw_response(self): + response = self.sdk.get_note(noteId="a/b?#") + request = self.requests[0] + self.assertEqual(request.url.raw_path, b"/custom/v1/notes/a%2Fb%3F%23") + self.assertEqual(request.url.host, "example.test") + self.assertEqual(request.headers["Authorization"], "Bearer test-token") + self.assertIsInstance(response, httpx.Response) + self.assertEqual(response.json(), {"content": "hello"}) + + def test_team_path_and_query(self): + self.sdk.get_team_note(teampath="docs", noteId="note-1") + self.assertEqual(self.requests[-1].url.path, "/custom/v1/teams/docs/notes/note-1") + self.sdk.get_history(limit=7) + self.assertEqual(str(self.requests[-1].url), "https://example.test/custom/v1/history?limit=7") + + def test_inline_json_body_accumulates_fields(self): + self.response = httpx.Response(202) + response = self.sdk.update_note(noteId="note-1", title="Title", content="Body") + request = self.requests[0] + self.assertEqual(request.method, "PATCH") + self.assertEqual(request.url.query, b"") + self.assertEqual(json.loads(request.content), {"title": "Title", "content": "Body"}) + self.assertEqual(response.status_code, 202) + self.assertEqual(response.content, b"") + + def test_pydantic_body_and_multistatus(self): + body = models.CreateNote.model_validate({"title": "Title", "parentFolderId": "folder-1"}) + result = {"note": {"id": "note-1"}, "error": "Folder unavailable"} + self.response = httpx.Response(207, json=result) + response = self.sdk.create_note(body=body) + request = self.requests[0] + self.assertEqual(request.method, "POST") + self.assertEqual(request.headers["Content-Type"], "application/json") + self.assertEqual(json.loads(request.content), body.model_dump(mode="json", by_alias=True)) + self.assertEqual(json.loads(request.content)["parentFolderId"], "folder-1") + self.assertEqual(response.status_code, 207) + self.assertEqual(response.json(), result) + + def test_no_content_conditional_request_and_error_response(self): + self.response = httpx.Response(204) + response = self.sdk.delete_note(noteId="note-1") + self.assertEqual(response.status_code, 204) + self.assertEqual(response.content, b"") + + self.client.headers["If-None-Match"] = 'W/"cached"' + self.response = httpx.Response(304, headers={"ETag": 'W/"cached"'}) + response = self.sdk.get_note(noteId="note-1") + self.assertEqual(self.requests[-1].headers["If-None-Match"], 'W/"cached"') + self.assertEqual(response.status_code, 304) + self.assertEqual(response.content, b"") + self.assertEqual(response.headers["ETag"], 'W/"cached"') + + self.response = httpx.Response(404, json={"error": "Note not found"}) + response = self.sdk.get_note(noteId="missing") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"error": "Note not found"}) + with self.assertRaises(httpx.HTTPStatusError): + response.raise_for_status() + + +if __name__ == "__main__": + unittest.main() diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..6da4a43 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,262 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hackmd-python-codegen-experiment" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = "==0.28.1" }, + { name = "pydantic", specifier = "==2.12.5" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] From f1c5a838c40d6129e63978a5b6850f17f8de6fa0 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Mon, 28 Sep 2026 01:30:31 +0800 Subject: [PATCH 4/7] feat: add Python API wrapper and live e2e tests --- python/README.md | 79 ++++- python/package.json | 3 +- .../@hey-api__openapi-python@0.0.24.patch | 14 +- python/patches/README.md | 9 +- python/pnpm-lock.yaml | 6 +- python/src/hackmd_api/__init__.py | 4 +- python/src/hackmd_api/api.py | 197 +++++++++++ .../hackmd_api/generated/client/client_gen.py | 6 +- .../src/hackmd_api/generated/pydantic_gen.py | 2 +- python/src/hackmd_api/generated/sdk_gen.py | 308 ++++++++++++------ python/tests/api.py | 224 +++++++++++++ python/tests/e2e/live.py | 181 ++++++++++ python/tests/smoke.py | 2 +- 13 files changed, 925 insertions(+), 110 deletions(-) create mode 100644 python/src/hackmd_api/api.py create mode 100644 python/tests/api.py create mode 100644 python/tests/e2e/live.py diff --git a/python/README.md b/python/README.md index 59794a8..e6525b4 100644 --- a/python/README.md +++ b/python/README.md @@ -16,8 +16,83 @@ The pinned generator includes a temporary [pnpm patch](./patches/README.md) adap 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. -`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. +`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. 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. -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. +## Custom API + +Run local examples with `PYTHONPATH=src uv run python your_example.py`; this experiment is not yet packaged for installation. + +```python +import os +from hackmd_api import API, models + +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) + + # All generated operations remain accessible; raw returns httpx.Response. + response = api.raw.list_webhooks() + response.raise_for_status() +``` + +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`. + +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": "..."})`. + +The following snippets belong inside the `with API(...) as api:` block above. + +```python +# Mutating example: only run against an account you intend to modify. +created = api.create_note(models.CreateNote(title="Example", content="# Hello")) +if isinstance(created, models.CreateNoteMultiStatusResponse): + # HTTP 207 still created a note; do not retry the creation. + print(created.error) + note_id = created.note.id +else: + note_id = created.id + +api.update_note(note_id, models.UpdateNoteBody(title="Updated")) +# Unset fields are omitted; explicit None is serialized as JSON null. +api.update_note(note_id, models.UpdateNoteBody.model_validate({"parentFolderId": None})) +api.delete_note(note_id) +``` + +`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. + +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. + +### ETag and raw responses + +```python +cached = api.get_note(note_id, unwrap_data=False) +cached_note = models.SingleNote.model_validate_json(cached.content) +etag = cached.headers.get("ETag") +note = api.get_note(note_id, etag=etag) +if note is None: # HTTP 304: keep your cached data + note = cached_note +``` + +`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. + +## Live E2E + +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. + +From `python/`, reuse the ignored `nodejs/.env` (`HACKMD_ACCESS_TOKEN`, optional `HACKMD_API_ENDPOINT`). Real environment variables override values in that file: + +```sh +HACKMD_E2E_MUTATIONS=0 pnpm test:e2e # read-only +HACKMD_E2E_MUTATIONS=1 pnpm test:e2e # creates/deletes notes, folders; uploads an image +``` + +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. + +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. + +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. + +Grouped parameters, async wrappers, packaging/publication, and wrappers for all operations remain outside this first slice. diff --git a/python/package.json b/python/package.json index 9e33ff5..0bf5a22 100644 --- a/python/package.json +++ b/python/package.json @@ -7,7 +7,8 @@ "codegen": "openapi-python", "check:generated": "node scripts/check-generated.mjs", "check": "python3 -m compileall -q src/hackmd_api", - "test": "uv run --frozen python tests/smoke.py" + "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" diff --git a/python/patches/@hey-api__openapi-python@0.0.24.patch b/python/patches/@hey-api__openapi-python@0.0.24.patch index a0ce80d..e1c550c 100644 --- a/python/patches/@hey-api__openapi-python@0.0.24.patch +++ b/python/patches/@hey-api__openapi-python@0.0.24.patch @@ -1,5 +1,5 @@ diff --git a/dist/clients/httpx/client.py b/dist/clients/httpx/client.py -index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce95f89ef7 100644 +index 2980b579df301513f3a9940b4a1af66946e27bb3..acf8110e65dd62bc9bd276b0daf0a706fdb7b9bc 100644 --- a/dist/clients/httpx/client.py +++ b/dist/clients/httpx/client.py @@ -1,6 +1,7 @@ @@ -43,7 +43,7 @@ index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce del result[slot] return result -@@ -86,6 +87,25 @@ class BaseClient: +@@ -86,6 +87,29 @@ class BaseClient: """Make an HTTP request.""" return self._client.request(method, url, **kwargs) @@ -52,17 +52,21 @@ index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce + 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) ++ request_options["json"] = body.model_dump(mode="json", by_alias=True, exclude_unset=True) + + return self.request(method, url, **request_options, **kwargs) + @@ -70,7 +74,7 @@ index 2980b579df301513f3a9940b4a1af66946e27bb3..e1e786192c118d0e9a02923b6cbcb2ce """Make a GET request.""" return self._client.get(url, **kwargs) diff --git a/dist/src-DaXm5pxY.mjs b/dist/src-DaXm5pxY.mjs -index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..7b024dd366c0384d4f3b5a4cf8ab55cbbf74f0a1 100644 +index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..175e17904bb0425037f3f44d8f86a874f3b7a08d 100644 --- a/dist/src-DaXm5pxY.mjs +++ b/dist/src-DaXm5pxY.mjs @@ -4290,7 +4290,7 @@ function implementFn(args) { @@ -78,7 +82,7 @@ index e4cdd77904daa50dc220b0abaa4ebb390e34ecff..7b024dd366c0384d4f3b5a4cf8ab55cb 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).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()); ++ 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()); } diff --git a/python/patches/README.md b/python/patches/README.md index 00acf9f..8062449 100644 --- a/python/patches/README.md +++ b/python/patches/README.md @@ -7,4 +7,11 @@ 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. -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. +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. diff --git a/python/pnpm-lock.yaml b/python/pnpm-lock.yaml index 99635a3..c4bc1d7 100644 --- a/python/pnpm-lock.yaml +++ b/python/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@hey-api/openapi-python@0.0.24': - hash: 10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189 + hash: 40dd7e88947df8f6d2c67034e45c96a1ad1311b91a94acd8f9d2769c4bb1dc96 path: patches/@hey-api__openapi-python@0.0.24.patch importers: @@ -15,7 +15,7 @@ importers: devDependencies: '@hey-api/openapi-python': specifier: 0.0.24 - version: 0.0.24(patch_hash=10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189) + version: 0.0.24(patch_hash=40dd7e88947df8f6d2c67034e45c96a1ad1311b91a94acd8f9d2769c4bb1dc96) packages: @@ -225,7 +225,7 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.2.0 - '@hey-api/openapi-python@0.0.24(patch_hash=10ad5111ab2d1db0d2c6afbe9ba1acfa43a02e3e0d240b65d102ea05a1166189)': + '@hey-api/openapi-python@0.0.24(patch_hash=40dd7e88947df8f6d2c67034e45c96a1ad1311b91a94acd8f9d2769c4bb1dc96)': dependencies: '@hey-api/codegen-core': 0.9.1 '@hey-api/json-schema-ref-parser': 1.4.4 diff --git a/python/src/hackmd_api/__init__.py b/python/src/hackmd_api/__init__.py index c1b6963..e29cf8c 100644 --- a/python/src/hackmd_api/__init__.py +++ b/python/src/hackmd_api/__init__.py @@ -1,5 +1,7 @@ """Experimental HackMD API client.""" from .generated import Sdk +from .generated import pydantic_gen as models +from .api import API, HttpResponseError, InternalServerError, TooManyRequestsError -__all__ = ["Sdk"] +__all__ = ["API", "Sdk", "models", "HttpResponseError", "InternalServerError", "TooManyRequestsError"] diff --git a/python/src/hackmd_api/api.py b/python/src/hackmd_api/api.py new file mode 100644 index 0000000..884d4ce --- /dev/null +++ b/python/src/hackmd_api/api.py @@ -0,0 +1,197 @@ +"""Small synchronous custom layer over the generated API client.""" + +from collections.abc import Callable +import time +from typing import Any, TypeVar + +import httpx +from pydantic import TypeAdapter + +from .generated import Sdk +from .generated import pydantic_gen as models + +T = TypeVar("T") + + +class HttpResponseError(httpx.HTTPStatusError): + """HTTP failure with the original request and response preserved.""" + + @property + def code(self) -> int: + return self.response.status_code + + +class InternalServerError(HttpResponseError): + """The server returned a 5xx response.""" + + +class TooManyRequestsError(HttpResponseError): + """Rate limited; original rate-limit headers are on ``response.headers``.""" + + +class API: + """Authenticated API client. Use as a context manager to close connections. + + Methods return generated Pydantic models (or None for no-content responses). + Pass ``unwrap_data=False`` for the original HTTPX response, including ETag. + ``raw`` exposes every generated operation without parsing, retries, or + automatic HTTP status errors. + Credentials are explicit: this class never reads environment variables/files. + """ + + def __init__( + self, + access_token: str, + base_url: str = "https://api.hackmd.io/v1", + *, + timeout: float = 30, + retries: int = 3, + retry_delay: float = 0.1, + wrap_response_errors: bool = True, + transport: httpx.BaseTransport | None = None, + ): + if not access_token.strip(): + raise ValueError("access_token is required") + if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0: + raise ValueError("retries must be a non-negative integer") + if retry_delay < 0: + raise ValueError("retry_delay must be non-negative") + self.raw = Sdk(client=httpx.Client( + base_url=base_url.rstrip("/"), + headers={"Authorization": f"Bearer {access_token.strip()}"}, + timeout=timeout, + transport=transport, + )) + self._retries = retries + self._retry_delay = retry_delay + self._wrap_response_errors = wrap_response_errors + + def close(self) -> None: + self.raw.close() + + def __enter__(self) -> "API": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _call( + self, operation: Callable[..., httpx.Response], model: type[T], *, + unwrap_data: bool, retry: bool = False, allow_not_modified: bool = False, + **kwargs: Any, + ) -> T | None | httpx.Response: + attempts = self._retries if retry else 0 + for attempt in range(attempts + 1): + try: + response = operation(**kwargs) + except httpx.TransportError: + if attempt == attempts: + raise + else: + transient = response.status_code == 429 or 500 <= response.status_code < 600 + remaining = response.headers.get("x-ratelimit-userremaining") + try: + exhausted = remaining is not None and float(remaining) <= 0 + except ValueError: + exhausted = False + if not transient or exhausted or attempt == attempts: + break + response.close() + time.sleep(self._retry_delay * 2 ** (attempt + 1)) + + if not (allow_not_modified and response.status_code == 304): + try: + response.raise_for_status() + except httpx.HTTPStatusError as error: + if not self._wrap_response_errors: + raise + error_type = ( + TooManyRequestsError if response.status_code == 429 + else InternalServerError if response.status_code >= 500 + else HttpResponseError + ) + # Do not include tokens or response bodies in exception messages. + raise error_type( + f"HTTP {response.status_code} {response.reason_phrase}", + request=response.request, response=response, + ) from error + if not unwrap_data: + return response + if response.status_code in (202, 204, 304): + return None + return TypeAdapter(model).validate_json(response.content) + + def get_me(self, *, unwrap_data: bool = True) -> models.User | httpx.Response: + return self._call(self.raw.get_me, models.User, retry=True, unwrap_data=unwrap_data) + + def list_teams(self, *, unwrap_data: bool = True) -> list[models.Team] | httpx.Response: + return self._call(self.raw.list_teams, list[models.Team], retry=True, unwrap_data=unwrap_data) + + def get_history(self, limit: int | None = None, *, unwrap_data: bool = True) -> list[models.Note] | httpx.Response: + return self._call(self.raw.get_history, list[models.Note], limit=limit, retry=True, unwrap_data=unwrap_data) + + def list_notes(self, *, unwrap_data: bool = True) -> list[models.NoteType] | httpx.Response: + return self._call(self.raw.list_notes, list[models.NoteType], retry=True, unwrap_data=unwrap_data) + + def get_note(self, note_id: str, *, etag: str | None = None, unwrap_data: bool = True) -> models.SingleNote | None | httpx.Response: + """Return a note, or None on conditional 304. Raw mode exposes the ETag.""" + return self._call( + self.raw.get_note, models.SingleNote, noteId=note_id, retry=True, + request_overrides={"headers": {"If-None-Match": etag}} if etag is not None else None, + allow_not_modified=etag is not None, unwrap_data=unwrap_data, + ) + + def get_team_note(self, team_path: str, note_id: str, *, etag: str | None = None, unwrap_data: bool = True) -> models.SingleNote | None | httpx.Response: + """Like get_note, scoped to a team workspace.""" + return self._call( + self.raw.get_team_note, models.SingleNote, teampath=team_path, noteId=note_id, + request_overrides={"headers": {"If-None-Match": etag}} if etag is not None else None, + retry=True, allow_not_modified=etag is not None, unwrap_data=unwrap_data, + ) + + def create_note(self, body: models.CreateNote, *, unwrap_data: bool = True) -> models.SingleNote | models.CreateNoteMultiStatusResponse | httpx.Response: + """A 207 returns CreateNoteMultiStatusResponse; the note was still created.""" + return self._call( + self.raw.create_note, models.SingleNote | models.CreateNoteMultiStatusResponse, + body=body, unwrap_data=unwrap_data, + ) + + def update_note(self, note_id: str, body: models.UpdateNoteBody, *, unwrap_data: bool = True) -> None | httpx.Response: + # Preserve explicit nulls while omitting unset fields; flat raw defaults + # cannot distinguish those two cases yet. + return self._call( + self.raw.update_note, type(None), noteId=note_id, + request_overrides={"json": body}, unwrap_data=unwrap_data, + ) + + def delete_note(self, note_id: str, *, unwrap_data: bool = True) -> None | httpx.Response: + return self._call(self.raw.delete_note, type(None), noteId=note_id, retry=True, unwrap_data=unwrap_data) + + def upload_note_image(self, note_id: str, image: bytes, *, filename: str = "image.png", content_type: str = "image/png", unwrap_data: bool = True) -> models.NoteImageUploadResponse | httpx.Response: + """Serialize multipart through the generated endpoint; never retry uploads.""" + return self._call( + self.raw.upload_note_image, models.NoteImageUploadResponse, noteId=note_id, image=image, + request_overrides={"files": {"image": (filename, image, content_type)}}, + unwrap_data=unwrap_data, + ) + + def list_folders(self, *, unwrap_data: bool = True) -> list[models.ApiFolder] | httpx.Response: + return self._call(self.raw.list_folders, list[models.ApiFolder], retry=True, unwrap_data=unwrap_data) + + def create_folder(self, body: models.CreateUserFolderBody, *, unwrap_data: bool = True) -> models.ApiFolder | httpx.Response: + return self._call(self.raw.create_folder, models.ApiFolder, create_user_folder_body=body, unwrap_data=unwrap_data) + + def get_folder(self, folder_id: str, *, unwrap_data: bool = True) -> models.ApiFolder | httpx.Response: + return self._call(self.raw.get_folder, models.ApiFolder, folderId=folder_id, retry=True, unwrap_data=unwrap_data) + + def update_folder(self, folder_id: str, body: models.UpdateUserFolderBody, *, unwrap_data: bool = True) -> None | httpx.Response: + return self._call(self.raw.update_folder, type(None), folderId=folder_id, update_user_folder_body=body, unwrap_data=unwrap_data) + + def delete_folder(self, folder_id: str, *, unwrap_data: bool = True) -> None | httpx.Response: + return self._call(self.raw.delete_folder, type(None), folderId=folder_id, retry=True, unwrap_data=unwrap_data) + + def get_folder_order(self, *, unwrap_data: bool = True) -> models.ApiFolderOrder | httpx.Response: + return self._call(self.raw.get_folder_order, models.ApiFolderOrder, retry=True, unwrap_data=unwrap_data) + + def update_folder_order(self, body: models.UpdateFolderOrderBody, *, unwrap_data: bool = True) -> None | httpx.Response: + return self._call(self.raw.update_folder_order, type(None), update_folder_order_body=body, retry=True, unwrap_data=unwrap_data) diff --git a/python/src/hackmd_api/generated/client/client_gen.py b/python/src/hackmd_api/generated/client/client_gen.py index 1939fbd..b65a1d5 100644 --- a/python/src/hackmd_api/generated/client/client_gen.py +++ b/python/src/hackmd_api/generated/client/client_gen.py @@ -94,17 +94,21 @@ def request_options( 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) + request_options["json"] = body.model_dump(mode="json", by_alias=True, exclude_unset=True) return self.request(method, url, **request_options, **kwargs) diff --git a/python/src/hackmd_api/generated/pydantic_gen.py b/python/src/hackmd_api/generated/pydantic_gen.py index 54be87e..0591fcf 100644 --- a/python/src/hackmd_api/generated/pydantic_gen.py +++ b/python/src/hackmd_api/generated/pydantic_gen.py @@ -431,7 +431,7 @@ class TeamVisibilityType(str, Enum): class Team(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") id: str - owner_id: str = Field(..., alias="ownerId") + owner_id: Optional[str] = Field(default=None, alias="ownerId") name: str logo: str path: str diff --git a/python/src/hackmd_api/generated/sdk_gen.py b/python/src/hackmd_api/generated/sdk_gen.py index b6c0bdf..0004088 100644 --- a/python/src/hackmd_api/generated/sdk_gen.py +++ b/python/src/hackmd_api/generated/sdk_gen.py @@ -12,107 +12,139 @@ def list_webhooks(self): return self.client.get("/webhooks") - def create_webhook(self, create_api_webhook_body: CreateApiWebhookBody): + def create_webhook(self, create_api_webhook_body: CreateApiWebhookBody, request_overrides: Optional[dict[str, Any]] = None): """Create a webhook in the current user's workspace. The signing secret is returned only once and cannot be retrieved later. """ params = build_client_params([{"in": "body", "key": "create_api_webhook_body", "map": "body"}], create_api_webhook_body=create_api_webhook_body) - return self.request_options("post", "/webhooks", params) + return self.request_options("post", "/webhooks", params, request_overrides) - def delete_webhook(self, hookId: str): + def delete_webhook(self, hookId: str, request_overrides: Optional[dict[str, Any]] = None): """Delete a webhook.""" params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) - return self.request_options("delete", "/webhooks/{hookId}", params) + return self.request_options("delete", "/webhooks/{hookId}", params, request_overrides) - def get_webhook(self, hookId: str): + def get_webhook(self, hookId: str, request_overrides: Optional[dict[str, Any]] = None): """Get a webhook in the current user's workspace.""" params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) - return self.request_options("get", "/webhooks/{hookId}", params) + return self.request_options("get", "/webhooks/{hookId}", params, request_overrides) - def update_webhook(self, hookId: str, update_webhook_data: UpdateWebhookData): + def update_webhook( + self, + hookId: str, + update_webhook_data: UpdateWebhookData, + request_overrides: Optional[dict[str, Any]] = None, + ): """Update a webhook. At least one of name, url, or active is required. Scope and event selection cannot be changed. """ params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "body", "key": "update_webhook_data", "map": "body"}], hookId=hookId, update_webhook_data=update_webhook_data) - return self.request_options("patch", "/webhooks/{hookId}", params) + return self.request_options("patch", "/webhooks/{hookId}", params, request_overrides) - def ping_webhook(self, hookId: str): + def ping_webhook(self, hookId: str, request_overrides: Optional[dict[str, Any]] = None): """Send a test delivery. Read the result from the deliveries endpoint.""" params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) - return self.request_options("post", "/webhooks/{hookId}/ping", params) + return self.request_options("post", "/webhooks/{hookId}/ping", params, request_overrides) def list_webhook_deliveries( self, hookId: str, page: Optional[ApiWebhookPage] = None, limit: Optional[ApiWebhookLimit] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """List webhook deliveries within the workspace plan's retention window.""" params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], hookId=hookId, page=page, limit=limit) - return self.request_options("get", "/webhooks/{hookId}/deliveries", params) + return self.request_options("get", "/webhooks/{hookId}/deliveries", params, request_overrides) - def export_webhook_deliveries(self, hookId: str): + def export_webhook_deliveries(self, hookId: str, request_overrides: Optional[dict[str, Any]] = None): """Download webhook deliveries as newline-delimited JSON.""" params = build_client_params([{"in": "path", "key": "hookId"}], hookId=hookId) - return self.request_options("get", "/webhooks/{hookId}/deliveries/export", params) + return self.request_options("get", "/webhooks/{hookId}/deliveries/export", params, request_overrides) - def get_webhook_delivery(self, hookId: str, deliveryId: str): + def get_webhook_delivery( + self, + hookId: str, + deliveryId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get a webhook delivery within the workspace plan's retention window.""" params = build_client_params([{"in": "path", "key": "hookId"}, {"in": "path", "key": "deliveryId"}], hookId=hookId, deliveryId=deliveryId) - return self.request_options("get", "/webhooks/{hookId}/deliveries/{deliveryId}", params) + return self.request_options("get", "/webhooks/{hookId}/deliveries/{deliveryId}", params, request_overrides) - def list_team_webhooks(self, teampath: str): + def list_team_webhooks(self, teampath: str, request_overrides: Optional[dict[str, Any]] = None): """List webhooks in a team workspace.""" params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) - return self.request_options("get", "/teams/{teampath}/webhooks", params) + return self.request_options("get", "/teams/{teampath}/webhooks", params, request_overrides) - def create_team_webhook(self, teampath: str, create_api_webhook_body: CreateApiWebhookBody): + def create_team_webhook( + self, + teampath: str, + create_api_webhook_body: CreateApiWebhookBody, + request_overrides: Optional[dict[str, Any]] = None, + ): """Create a webhook in a team workspace. The signing secret is returned only once and cannot be retrieved later. """ params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "create_api_webhook_body", "map": "body"}], teampath=teampath, create_api_webhook_body=create_api_webhook_body) - return self.request_options("post", "/teams/{teampath}/webhooks", params) + return self.request_options("post", "/teams/{teampath}/webhooks", params, request_overrides) - def delete_team_webhook(self, teampath: str, hookId: str): + def delete_team_webhook( + self, + teampath: str, + hookId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Delete a team webhook.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) - return self.request_options("delete", "/teams/{teampath}/webhooks/{hookId}", params) + return self.request_options("delete", "/teams/{teampath}/webhooks/{hookId}", params, request_overrides) - def get_team_webhook(self, teampath: str, hookId: str): + def get_team_webhook( + self, + teampath: str, + hookId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get a webhook in a team workspace.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) - return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}", params) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}", params, request_overrides) def update_team_webhook( self, teampath: str, hookId: str, update_webhook_data: UpdateWebhookData, + request_overrides: Optional[dict[str, Any]] = None, ): """Update a team webhook. At least one of name, url, or active is required. Scope and event selection cannot be changed. """ params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "body", "key": "update_webhook_data", "map": "body"}], teampath=teampath, hookId=hookId, update_webhook_data=update_webhook_data) - return self.request_options("patch", "/teams/{teampath}/webhooks/{hookId}", params) + return self.request_options("patch", "/teams/{teampath}/webhooks/{hookId}", params, request_overrides) - def ping_team_webhook(self, teampath: str, hookId: str): + def ping_team_webhook( + self, + teampath: str, + hookId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Send a test delivery for a team webhook.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) - return self.request_options("post", "/teams/{teampath}/webhooks/{hookId}/ping", params) + return self.request_options("post", "/teams/{teampath}/webhooks/{hookId}/ping", params, request_overrides) def list_team_webhook_deliveries( self, @@ -120,51 +152,58 @@ def list_team_webhook_deliveries( hookId: str, page: Optional[ApiWebhookPage] = None, limit: Optional[ApiWebhookLimit] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """List team webhook deliveries within the plan's retention window.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], teampath=teampath, hookId=hookId, page=page, limit=limit) - return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries", params) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries", params, request_overrides) - def export_team_webhook_deliveries(self, teampath: str, hookId: str): + def export_team_webhook_deliveries( + self, + teampath: str, + hookId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Download team webhook deliveries as newline-delimited JSON.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}], teampath=teampath, hookId=hookId) - return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/export", params) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/export", params, request_overrides) def get_team_webhook_delivery( self, teampath: str, hookId: str, deliveryId: str, + request_overrides: Optional[dict[str, Any]] = None, ): """Get a team webhook delivery within the plan's retention window.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "hookId"}, {"in": "path", "key": "deliveryId"}], teampath=teampath, hookId=hookId, deliveryId=deliveryId) - return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/{deliveryId}", params) + return self.request_options("get", "/teams/{teampath}/webhooks/{hookId}/deliveries/{deliveryId}", params, request_overrides) def list_notes(self): """List all notes for the current user""" return self.client.get("/notes") - def create_note(self, body: Optional[Any] = None): + def create_note(self, body: Optional[Any] = None, request_overrides: Optional[dict[str, Any]] = None): """Create a new note for the current user""" params = build_client_params([{"in": "body", "key": "body", "map": "body"}], body=body) - return self.request_options("post", "/notes", params) + return self.request_options("post", "/notes", params, request_overrides) - def delete_note(self, noteId: str): + def delete_note(self, noteId: str, request_overrides: Optional[dict[str, Any]] = None): """Delete a note for the current user""" params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) - return self.request_options("delete", "/notes/{noteId}", params) + return self.request_options("delete", "/notes/{noteId}", params, request_overrides) - def get_note(self, noteId: str): + def get_note(self, noteId: str, request_overrides: Optional[dict[str, Any]] = None): """Get a single note for the current user (or team note if accessible)""" params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) - return self.request_options("get", "/notes/{noteId}", params) + return self.request_options("get", "/notes/{noteId}", params, request_overrides) def update_note( self, @@ -177,22 +216,23 @@ def update_note( description: Optional[str] = None, tags: Optional[list[str]] = None, title: Optional[str] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """Update a note's content or permissions for the current user""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "parentFolderId"}, {"in": "body", "key": "permalink"}, {"in": "body", "key": "writePermission"}, {"in": "body", "key": "readPermission"}, {"in": "body", "key": "content"}, {"in": "body", "key": "description"}, {"in": "body", "key": "tags"}, {"in": "body", "key": "title"}], noteId=noteId, parentFolderId=parentFolderId, permalink=permalink, writePermission=writePermission, readPermission=readPermission, content=content, description=description, tags=tags, title=title) - return self.request_options("patch", "/notes/{noteId}", params) + return self.request_options("patch", "/notes/{noteId}", params, request_overrides) def list_folders(self): """List all folders in the current user's workspace""" return self.client.get("/folders") - def create_folder(self, create_user_folder_body: Optional[CreateUserFolderBody] = None): + def create_folder(self, create_user_folder_body: Optional[CreateUserFolderBody] = None, request_overrides: Optional[dict[str, Any]] = None): """Create a new folder in the current user's workspace""" params = build_client_params([{"in": "body", "key": "create_user_folder_body", "map": "body"}], create_user_folder_body=create_user_folder_body) - return self.request_options("post", "/folders", params) + return self.request_options("post", "/folders", params, request_overrides) def get_folder_order(self): """Get your personal folder ordering for this workspace (parent folder id or `root` → ordered child folder ids). @@ -201,81 +241,101 @@ def get_folder_order(self): return self.client.get("/folders/folder-order") - def update_folder_order(self, update_folder_order_body: Optional[UpdateFolderOrderBody] = None): + def update_folder_order(self, update_folder_order_body: Optional[UpdateFolderOrderBody] = None, request_overrides: Optional[dict[str, Any]] = None): """Replace your personal folder ordering for this workspace.""" params = build_client_params([{"in": "body", "key": "update_folder_order_body", "map": "body"}], update_folder_order_body=update_folder_order_body) - return self.request_options("put", "/folders/folder-order", params) + return self.request_options("put", "/folders/folder-order", params, request_overrides) - def delete_folder(self, folderId: str): + def delete_folder(self, folderId: str, request_overrides: Optional[dict[str, Any]] = None): """Delete a folder in the current user's workspace""" params = build_client_params([{"in": "path", "key": "folderId"}], folderId=folderId) - return self.request_options("delete", "/folders/{folderId}", params) + return self.request_options("delete", "/folders/{folderId}", params, request_overrides) - def get_folder(self, folderId: str): + def get_folder(self, folderId: str, request_overrides: Optional[dict[str, Any]] = None): """Get a single folder in the current user's workspace""" params = build_client_params([{"in": "path", "key": "folderId"}], folderId=folderId) - return self.request_options("get", "/folders/{folderId}", params) + return self.request_options("get", "/folders/{folderId}", params, request_overrides) - def update_folder(self, folderId: str, update_user_folder_body: Optional[UpdateUserFolderBody] = None): + def update_folder( + self, + folderId: str, + update_user_folder_body: Optional[UpdateUserFolderBody] = None, + request_overrides: Optional[dict[str, Any]] = None, + ): """Update a folder in the current user's workspace""" params = build_client_params([{"in": "path", "key": "folderId"}, {"in": "body", "key": "update_user_folder_body", "map": "body"}], folderId=folderId, update_user_folder_body=update_user_folder_body) - return self.request_options("patch", "/folders/{folderId}", params) + return self.request_options("patch", "/folders/{folderId}", params, request_overrides) def list_trash(self): """List trashed notes in your personal workspace (same data as the internal trash API).""" return self.client.get("/trash") - def batch_restore(self, batch_restore_trash_body: BatchRestoreTrashBody): + def batch_restore(self, batch_restore_trash_body: BatchRestoreTrashBody, request_overrides: Optional[dict[str, Any]] = None): """Restore multiple notes from trash in one request.""" params = build_client_params([{"in": "body", "key": "batch_restore_trash_body", "map": "body"}], batch_restore_trash_body=batch_restore_trash_body) - return self.request_options("put", "/trash/batch-restore", params) + return self.request_options("put", "/trash/batch-restore", params, request_overrides) - def restore_note(self, noteId: str): + def restore_note(self, noteId: str, request_overrides: Optional[dict[str, Any]] = None): """Restore a single note from trash.""" params = build_client_params([{"in": "path", "key": "noteId"}], noteId=noteId) - return self.request_options("put", "/trash/{noteId}/restore", params) + return self.request_options("put", "/trash/{noteId}/restore", params, request_overrides) - def list_team_trash(self, teampath: str): + def list_team_trash(self, teampath: str, request_overrides: Optional[dict[str, Any]] = None): """List trashed notes in a team workspace (team admin only; same rules as the internal API).""" params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) - return self.request_options("get", "/teams/{teampath}/trash", params) + return self.request_options("get", "/teams/{teampath}/trash", params, request_overrides) def list_teams(self): """List the teams for the current user""" return self.client.get("/teams") - def list_team_notes(self, teampath: str): + def list_team_notes(self, teampath: str, request_overrides: Optional[dict[str, Any]] = None): """List all notes for a team""" params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) - return self.request_options("get", "/teams/{teampath}/notes", params) + return self.request_options("get", "/teams/{teampath}/notes", params, request_overrides) - def create_team_note(self, teampath: str, body: Optional[Any] = None): + def create_team_note( + self, + teampath: str, + body: Optional[Any] = None, + request_overrides: Optional[dict[str, Any]] = None, + ): """Create a new note for a team""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "body", "map": "body"}], teampath=teampath, body=body) - return self.request_options("post", "/teams/{teampath}/notes", params) + return self.request_options("post", "/teams/{teampath}/notes", params, request_overrides) - def delete_team_note(self, teampath: str, noteId: str): + def delete_team_note( + self, + teampath: str, + noteId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Delete a team note""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}], teampath=teampath, noteId=noteId) - return self.request_options("delete", "/teams/{teampath}/notes/{noteId}", params) + return self.request_options("delete", "/teams/{teampath}/notes/{noteId}", params, request_overrides) - def get_team_note(self, teampath: str, noteId: str): + def get_team_note( + self, + teampath: str, + noteId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get a single note for a team""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}], teampath=teampath, noteId=noteId) - return self.request_options("get", "/teams/{teampath}/notes/{noteId}", params) + return self.request_options("get", "/teams/{teampath}/notes/{noteId}", params, request_overrides) def update_team_note( self, @@ -289,60 +349,82 @@ def update_team_note( description: Optional[str] = None, tags: Optional[list[str]] = None, title: Optional[str] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """Update a team note""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "noteId"}, {"in": "body", "key": "parentFolderId"}, {"in": "body", "key": "permalink"}, {"in": "body", "key": "writePermission"}, {"in": "body", "key": "readPermission"}, {"in": "body", "key": "content"}, {"in": "body", "key": "description"}, {"in": "body", "key": "tags"}, {"in": "body", "key": "title"}], teampath=teampath, noteId=noteId, parentFolderId=parentFolderId, permalink=permalink, writePermission=writePermission, readPermission=readPermission, content=content, description=description, tags=tags, title=title) - return self.request_options("patch", "/teams/{teampath}/notes/{noteId}", params) + return self.request_options("patch", "/teams/{teampath}/notes/{noteId}", params, request_overrides) - def list_team_folders(self, teampath: str): + def list_team_folders(self, teampath: str, request_overrides: Optional[dict[str, Any]] = None): """List all folders in a team workspace""" params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) - return self.request_options("get", "/teams/{teampath}/folders", params) + return self.request_options("get", "/teams/{teampath}/folders", params, request_overrides) - def create_team_folder(self, teampath: str, create_team_folder_body: Optional[CreateTeamFolderBody] = None): + def create_team_folder( + self, + teampath: str, + create_team_folder_body: Optional[CreateTeamFolderBody] = None, + request_overrides: Optional[dict[str, Any]] = None, + ): """Create a new folder in a team workspace""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "create_team_folder_body", "map": "body"}], teampath=teampath, create_team_folder_body=create_team_folder_body) - return self.request_options("post", "/teams/{teampath}/folders", params) + return self.request_options("post", "/teams/{teampath}/folders", params, request_overrides) - def get_team_folder_order(self, teampath: str): + def get_team_folder_order(self, teampath: str, request_overrides: Optional[dict[str, Any]] = None): """Get your personal folder ordering for this team workspace (parent folder id or `root` → ordered child folder ids). Uses the same folder UUIDs as (not internal Yjs clientIds). """ params = build_client_params([{"in": "path", "key": "teampath"}], teampath=teampath) - return self.request_options("get", "/teams/{teampath}/folders/folder-order", params) + return self.request_options("get", "/teams/{teampath}/folders/folder-order", params, request_overrides) - def update_team_folder_order(self, teampath: str, update_folder_order_body: Optional[UpdateFolderOrderBody] = None): + def update_team_folder_order( + self, + teampath: str, + update_folder_order_body: Optional[UpdateFolderOrderBody] = None, + request_overrides: Optional[dict[str, Any]] = None, + ): """Replace your personal folder ordering for this team workspace.""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "body", "key": "update_folder_order_body", "map": "body"}], teampath=teampath, update_folder_order_body=update_folder_order_body) - return self.request_options("put", "/teams/{teampath}/folders/folder-order", params) + return self.request_options("put", "/teams/{teampath}/folders/folder-order", params, request_overrides) - def delete_team_folder(self, teampath: str, folderId: str): + def delete_team_folder( + self, + teampath: str, + folderId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Delete a folder in a team workspace""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}], teampath=teampath, folderId=folderId) - return self.request_options("delete", "/teams/{teampath}/folders/{folderId}", params) + return self.request_options("delete", "/teams/{teampath}/folders/{folderId}", params, request_overrides) - def get_team_folder(self, teampath: str, folderId: str): + def get_team_folder( + self, + teampath: str, + folderId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get a single folder in a team workspace""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}], teampath=teampath, folderId=folderId) - return self.request_options("get", "/teams/{teampath}/folders/{folderId}", params) + return self.request_options("get", "/teams/{teampath}/folders/{folderId}", params, request_overrides) def update_team_folder( self, teampath: str, folderId: str, update_team_folder_body: Optional[UpdateTeamFolderBody] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """Update a folder in a team workspace""" params = build_client_params([{"in": "path", "key": "teampath"}, {"in": "path", "key": "folderId"}, {"in": "body", "key": "update_team_folder_body", "map": "body"}], teampath=teampath, folderId=folderId, update_team_folder_body=update_team_folder_body) - return self.request_options("patch", "/teams/{teampath}/folders/{folderId}", params) + return self.request_options("patch", "/teams/{teampath}/folders/{folderId}", params, request_overrides) def list_versions( self, @@ -354,6 +436,7 @@ def list_versions( created_before: Optional[str] = None, page: Optional[float] = None, limit: Optional[float] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """List note versions @@ -361,9 +444,14 @@ def list_versions( """ params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "named_only"}, {"in": "query", "key": "q"}, {"in": "query", "key": "created_by"}, {"in": "query", "key": "created_after"}, {"in": "query", "key": "created_before"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}], noteId=noteId, named_only=named_only, q=q, created_by=created_by, created_after=created_after, created_before=created_before, page=page, limit=limit) - return self.request_options("get", "/notes/{noteId}/versions", params) + return self.request_options("get", "/notes/{noteId}/versions", params, request_overrides) - def update_version(self, noteId: str, update_note_version_body: UpdateNoteVersionBody): + def update_version( + self, + noteId: str, + update_note_version_body: UpdateNoteVersionBody, + request_overrides: Optional[dict[str, Any]] = None, + ): """Update named version metadata Update an existing named version's metadata with `PATCH /notes/{noteId}/versions` @@ -372,9 +460,14 @@ def update_version(self, noteId: str, update_note_version_body: UpdateNoteVersio """ params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "update_note_version_body", "map": "body"}], noteId=noteId, update_note_version_body=update_note_version_body) - return self.request_options("patch", "/notes/{noteId}/versions", params) + return self.request_options("patch", "/notes/{noteId}/versions", params, request_overrides) - def create_version(self, noteId: str, create_note_version_body: CreateNoteVersionBody): + def create_version( + self, + noteId: str, + create_note_version_body: CreateNoteVersionBody, + request_overrides: Optional[dict[str, Any]] = None, + ): """Create a named note version Create a named version from live note content or an existing saved version. When @@ -386,13 +479,14 @@ def create_version(self, noteId: str, create_note_version_body: CreateNoteVersio """ params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "create_note_version_body", "map": "body"}], noteId=noteId, create_note_version_body=create_note_version_body) - return self.request_options("post", "/notes/{noteId}/versions", params) + return self.request_options("post", "/notes/{noteId}/versions", params, request_overrides) def compare_versions( self, noteId: str, base: str, target: str, + request_overrides: Optional[dict[str, Any]] = None, ): """Compare note versions @@ -400,22 +494,32 @@ def compare_versions( """ params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "base"}, {"in": "query", "key": "target"}], noteId=noteId, base=base, target=target) - return self.request_options("get", "/notes/{noteId}/versions/compare", params) + return self.request_options("get", "/notes/{noteId}/versions/compare", params, request_overrides) - def get_version(self, noteId: str, versionId: str): + def get_version( + self, + noteId: str, + versionId: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get a note version Get one saved version with reconstructed content when available. """ params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "versionId"}], noteId=noteId, versionId=versionId) - return self.request_options("get", "/notes/{noteId}/versions/{versionId}", params) + return self.request_options("get", "/notes/{noteId}/versions/{versionId}", params, request_overrides) - def upload_note_image(self, noteId: str, image: str): + def upload_note_image( + self, + noteId: str, + image: str, + request_overrides: Optional[dict[str, Any]] = None, + ): """Upload an image for a note.""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "body", "key": "image"}], noteId=noteId, image=image) - return self.request_options("post", "/notes/{noteId}/images", params) + return self.request_options("post", "/notes/{noteId}/images", params, request_overrides) def list_note_comments( self, @@ -427,37 +531,53 @@ def list_note_comments( commentStatus: Optional[ApiCommentStatus] = None, threadStatus: Optional[ApiCommentStatus] = None, isThreadHead: Optional[Any] = None, + request_overrides: Optional[dict[str, Any]] = None, ): """List comments and replies visible on a note.""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "query", "key": "page"}, {"in": "query", "key": "limit"}, {"in": "query", "key": "sort"}, {"in": "query", "key": "threadId"}, {"in": "query", "key": "commentStatus"}, {"in": "query", "key": "threadStatus"}, {"in": "query", "key": "isThreadHead"}], noteId=noteId, page=page, limit=limit, sort=sort, threadId=threadId, commentStatus=commentStatus, threadStatus=threadStatus, isThreadHead=isThreadHead) - return self.request_options("get", "/notes/{noteId}/comments", params) + return self.request_options("get", "/notes/{noteId}/comments", params, request_overrides) - def get_note_comment(self, noteId: str, commentId: ApiCommentId): + def get_note_comment( + self, + noteId: str, + commentId: ApiCommentId, + request_overrides: Optional[dict[str, Any]] = None, + ): """Get one comment or reply from a note.""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) - return self.request_options("get", "/notes/{noteId}/comments/{commentId}", params) + return self.request_options("get", "/notes/{noteId}/comments/{commentId}", params, request_overrides) - def unresolve_note_comment(self, noteId: str, commentId: ApiCommentId): + def unresolve_note_comment( + self, + noteId: str, + commentId: ApiCommentId, + request_overrides: Optional[dict[str, Any]] = None, + ): """Unresolve a comment or thread.""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) - return self.request_options("delete", "/notes/{noteId}/comments/{commentId}/resolution", params) + return self.request_options("delete", "/notes/{noteId}/comments/{commentId}/resolution", params, request_overrides) - def resolve_note_comment(self, noteId: str, commentId: ApiCommentId): + def resolve_note_comment( + self, + noteId: str, + commentId: ApiCommentId, + request_overrides: Optional[dict[str, Any]] = None, + ): """Resolve a comment or thread.""" params = build_client_params([{"in": "path", "key": "noteId"}, {"in": "path", "key": "commentId"}], noteId=noteId, commentId=commentId) - return self.request_options("put", "/notes/{noteId}/comments/{commentId}/resolution", params) + return self.request_options("put", "/notes/{noteId}/comments/{commentId}/resolution", params, request_overrides) def get_me(self): """Get the current user's profile""" return self.client.get("/me") - def get_history(self, limit: Optional[float] = None): + def get_history(self, limit: Optional[float] = None, request_overrides: Optional[dict[str, Any]] = None): """Get note history for the current user""" params = build_client_params([{"in": "query", "key": "limit"}], limit=limit) - return self.request_options("get", "/history", params) + return self.request_options("get", "/history", params, request_overrides) diff --git a/python/tests/api.py b/python/tests/api.py new file mode 100644 index 0000000..7e71a17 --- /dev/null +++ b/python/tests/api.py @@ -0,0 +1,224 @@ +"""Wrapper behavior using real HTTPX serialization, with no network access.""" + +import json +from pathlib import Path +import sys +import unittest + +import httpx +from pydantic import ValidationError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from hackmd_api import API, HttpResponseError, InternalServerError, TooManyRequestsError, models + + +NOTE = { + "id": "note-1", "title": "Title", "content": "Body", "description": "", + "tags": [], "createdAt": 1, "lastChangedAt": 2, "publishType": "view", + "shortId": "short", "publishLink": "https://example.test/s/short", + "readPermission": "owner", "writePermission": "owner", +} +FOLDER = {"id": "folder-1", "name": "Folder", "createdAt": 1, "updatedAt": 2} + + +class APITest(unittest.TestCase): + def setUp(self): + self.requests = [] + self.responses = [] + + def handle(request): + self.requests.append(request) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + self.api = API(" test-token ", "https://example.test/custom/v1/", retries=2, + retry_delay=0, transport=httpx.MockTransport(handle)) + self.addCleanup(self.api.close) + + def respond(self, code=200, body=None, headers=None): + self.responses.append(httpx.Response(code, json=body, headers=headers)) + + def test_note_etag_and_headers_do_not_leak(self): + self.respond(body=NOTE, headers={"ETag": 'W/"one"'}) + response = self.api.get_note("a/b?#", unwrap_data=False) + self.assertEqual(response.headers["ETag"], 'W/"one"') + self.assertEqual(self.requests[-1].url.raw_path, b"/custom/v1/notes/a%2Fb%3F%23") + self.assertEqual(self.requests[-1].headers["Authorization"], "Bearer test-token") + self.respond(304, headers={"ETag": 'W/"one"'}) + self.assertIsNone(self.api.get_note("note-1", etag='W/"one"')) + self.assertEqual(self.requests[-1].headers["If-None-Match"], 'W/"one"') + self.respond(body=NOTE) + note = self.api.get_team_note("docs", "note-1") + self.assertIsInstance(note, models.SingleNote) + self.assertEqual(note.content, "Body") + self.assertNotIn("If-None-Match", self.requests[-1].headers) + self.assertEqual(self.requests[-1].url.path, "/custom/v1/teams/docs/notes/note-1") + self.respond(304) + response = self.api.get_team_note("docs", "note-1", etag="cached", unwrap_data=False) + self.assertEqual(response.status_code, 304) + self.respond(304) + with self.assertRaises(HttpResponseError): + self.api.get_note("note-1") + + def test_create_multistatus_and_no_implicit_nulls(self): + for code, body in [(201, NOTE), (207, {"note": NOTE, "error": "Folder unavailable"})]: + with self.subTest(code=code): + self.respond(code, body) + result = self.api.create_note(models.CreateNote(title="Title", content="Body")) + self.assertEqual(json.loads(self.requests[-1].content), {"title": "Title", "content": "Body"}) + self.assertIsInstance(result, models.SingleNote if code == 201 else models.CreateNoteMultiStatusResponse) + + def test_patch_preserves_null_and_omits_unset(self): + self.respond(202) + body = models.UpdateNoteBody.model_validate({"title": "Changed", "parentFolderId": None}) + self.assertIsNone(self.api.update_note("note-1", body)) + self.assertEqual(json.loads(self.requests[-1].content), {"title": "Changed", "parentFolderId": None}) + self.respond(202) + self.api.update_folder("folder-1", models.UpdateUserFolderBody.model_validate({"description": None})) + self.assertEqual(json.loads(self.requests[-1].content), {"description": None}) + + def test_multipart_is_bytes_not_json(self): + self.respond(201, {"data": {"link": "https://example.test/image.png"}}) + result = self.api.upload_note_image("note-1", b"\x89PNG\x00\xff", filename="test.png") + request = self.requests[-1] + self.assertEqual(result.data.link, "https://example.test/image.png") + self.assertEqual(request.url.path, "/custom/v1/notes/note-1/images") + self.assertTrue(request.headers["Content-Type"].startswith("multipart/form-data; boundary=")) + self.assertIn(b'name="image"; filename="test.png"', request.content) + self.assertIn(b"Content-Type: image/png", request.content) + self.assertIn(b"\x89PNG\x00\xff", request.content) + + def test_nullable_team_owner_in_profile_and_team_list(self): + for owner_id in [None, "owner-id"]: + with self.subTest(owner_id=owner_id): + team = { + "id": "team-id", "ownerId": owner_id, "name": "Docs", + "logo": "", "path": "docs", "description": None, + "visibility": "private", "upgraded": False, "createdAt": 1, + } + self.respond(body=[team]) + self.assertEqual(self.api.list_teams()[0].owner_id, owner_id) + self.respond(body={ + "id": "u", "name": "User", "userPath": "user", "photo": "", + "teams": [team], "upgraded": False, + }) + self.assertEqual(self.api.get_me().teams[0].owner_id, owner_id) + + def test_profile_lists_history_and_folders(self): + self.respond(body={"id": "u", "name": "User", "userPath": "user", "photo": "", "teams": [], "upgraded": False}) + self.assertIsInstance(self.api.get_me(), models.User) + for operation in [self.api.list_teams, self.api.list_notes, self.api.list_folders]: + self.respond(body=[]) + self.assertEqual(operation(), []) + self.respond(body=[]) + self.assertEqual(self.api.get_history(7), []) + self.assertEqual(self.requests[-1].url.query, b"limit=7") + self.respond(201, FOLDER) + folder = self.api.create_folder(models.CreateUserFolderBody.model_validate({"name": "Folder", "parentFolderId": "parent"})) + self.assertEqual(folder.name.root, "Folder") + self.assertEqual(json.loads(self.requests[-1].content), {"name": "Folder", "parentFolderId": "parent"}) + self.respond(body=FOLDER) + self.assertEqual(self.api.get_folder("folder-1").id, "folder-1") + self.respond(body={"root": ["folder-1"]}) + order = self.api.get_folder_order() + self.assertEqual(order.root, {"root": ["folder-1"]}) + self.respond(204) + self.assertIsNone(self.api.update_folder_order(models.UpdateFolderOrderBody(order=order))) + self.assertEqual(json.loads(self.requests[-1].content), {"order": {"root": ["folder-1"]}}) + for operation in [self.api.delete_note, self.api.delete_folder]: + self.respond(204) + self.assertIsNone(operation("id")) + + def test_retry_is_per_call_and_safe_methods_only(self): + for _ in range(2): + self.responses.append(httpx.ConnectError("offline")) + self.respond(503) + self.respond(body=NOTE) + self.assertEqual(self.api.get_note("note-1").id, "note-1") + self.assertEqual(len(self.requests), 6) + self.respond(429) + self.respond(body=[]) + self.assertEqual(self.api.list_notes(), []) + for operation, args in [ + (self.api.create_note, (models.CreateNote(title="title"),)), + (self.api.update_note, ("note-1", models.UpdateNoteBody(title="title"))), + (self.api.upload_note_image, ("note-1", b"image")), + ]: + count = len(self.requests) + self.respond(503) + with self.assertRaises(InternalServerError): + operation(*args) + self.assertEqual(len(self.requests), count + 1) + self.respond(503) + self.respond(204) + self.api.delete_note("note-1") + + def test_errors_exhaustion_and_raw_escape_hatch(self): + self.respond(404, {"error": "Note not found"}) + with self.assertRaises(HttpResponseError) as caught: + self.api.get_note("missing") + self.assertEqual(caught.exception.code, 404) + self.assertEqual(caught.exception.response.json(), {"error": "Note not found"}) + self.respond(429, headers={"x-ratelimit-userremaining": "0"}) + with self.assertRaises(TooManyRequestsError): + self.api.get_note("note-1") + self.assertEqual(len(self.requests), 2) + for _ in range(3): + self.respond(503) + with self.assertRaises(InternalServerError): + self.api.list_notes() + self.assertEqual(len(self.requests), 5) + self.respond(503) + self.assertEqual(self.api.raw.list_notes().status_code, 503) + self.assertEqual(len(self.requests), 6) + + def test_retry_disabled_native_errors_and_validation(self): + with self.assertRaises(ValueError): + API(" ") + for value in [-1, 1.5, True]: + with self.assertRaises(ValueError): + API("token", retries=value) + with API("token", retries=0, wrap_response_errors=False, + transport=httpx.MockTransport(lambda _: httpx.Response(503))) as api: + with self.assertRaises(httpx.HTTPStatusError) as caught: + api.get_me() + self.assertNotIsInstance(caught.exception, HttpResponseError) + self.respond(body={"id": "incomplete"}) + with self.assertRaises(ValidationError): + self.api.get_note("note-1") + + def test_cleanup_and_live_failure_redaction(self): + import io + import runpy + live = runpy.run_path(str(Path(__file__).parent / "e2e/live.py")) + case = live["LiveE2E"]("test_profile") + deleted = [] + case.track("only-our-id", deleted.append) + case.doCleanups() + self.assertEqual(deleted, ["only-our-id"]) + + def broken_cleanup(_): + raise RuntimeError("cleanup failed") + case = live["LiveE2E"]("test_profile") + case.track("our-id", broken_cleanup) + case.test_profile = lambda: None + cleanup_result = unittest.TestResult() + case.run(cleanup_result) + self.assertEqual(len(cleanup_result.errors), 1) + self.assertIn("cleanup failed", cleanup_result.errors[0][1]) + + stream = io.StringIO() + payload = {"content": "private-note-body"} + def invalid_response(): + models.SingleNote.model_validate(payload) + result = unittest.TextTestRunner(stream=stream, resultclass=live["SafeResult"]).run( + unittest.FunctionTestCase(invalid_response) + ) + self.assertNotIn("private-note-body", result.errors[0][1]) + self.assertIn("Response schema mismatch", result.errors[0][1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/e2e/live.py b/python/tests/e2e/live.py new file mode 100644 index 0000000..d0b4bbc --- /dev/null +++ b/python/tests/e2e/live.py @@ -0,0 +1,181 @@ +"""Explicit live E2E entry point. Never discovered by the offline test command.""" + +import os +from pathlib import Path +import sys +import unittest +from uuid import uuid4 + +from pydantic import TypeAdapter, ValidationError + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) +from hackmd_api import API, HttpResponseError, models + +MUTATIONS = os.environ.get("HACKMD_E2E_MUTATIONS") == "1" +FOLDERS = os.environ.get("HACKMD_E2E_FOLDERS") != "0" + + +class LiveE2E(unittest.TestCase): + @classmethod + def setUpClass(cls): + token = os.environ.get("HACKMD_ACCESS_TOKEN", "").strip() + if not token: + raise RuntimeError("HACKMD_ACCESS_TOKEN is required (see python/README.md)") + endpoint = os.environ.get("HACKMD_API_ENDPOINT", "").strip() or "https://api.hackmd.io/v1" + cls.api = API(token, endpoint, retries=2, retry_delay=0.25) + cls.addClassCleanup(cls.api.close) + print(f"Live E2E: {endpoint}; mutations={MUTATIONS}; folders={FOLDERS}", flush=True) + + def test_profile(self): + user = self.api.get_me() + self.assertIsInstance(user.id, str) + self.assertIsInstance(user.name, str) + self.assertIsInstance(user.user_path, str) + self.assertIsInstance(user.teams, list) + + def test_notes_list(self): + notes = self.api.list_notes() + self.assertIsInstance(notes, list) + for note in notes[:1]: + self.assertIsInstance(note.id, str) + self.assertIsInstance(note.title, str) + + def test_teams(self): + self.assertIsInstance(self.api.list_teams(), list) + + def test_history(self): + self.assertIsInstance(self.api.get_history(7), list) + + def test_folders_list(self): + try: + self.assertIsInstance(self.api.list_folders(), list) + except HttpResponseError as error: + if error.code == 404: + self.skipTest("Server does not expose /folders") + raise + + def track(self, resource_id, delete): + # Register before any DTO validation/assertion can fail. Cleanup failures + # are unittest errors, not silently ignored. Only our exact IDs are used. + self.assertIsInstance(resource_id, str) + def cleanup(): + try: + delete(resource_id) + except HttpResponseError as error: + if error.code != 404: + raise RuntimeError(f"Cleanup failed for {resource_id}: HTTP {error.code}") from error + self.addCleanup(cleanup) + + @unittest.skipUnless(MUTATIONS, "Set HACKMD_E2E_MUTATIONS=1 to enable writes") + def test_notes_crud_and_image(self): + title = f"e2e-python-note-{uuid4().hex}" + response = self.api.create_note(models.CreateNote(title=title, content="# initial\n", tags=["e2e"]), unwrap_data=False) + wire = response.json() + note_id = wire["note"]["id"] if response.status_code == 207 else wire["id"] + self.track(note_id, self.api.delete_note) + created = TypeAdapter(models.SingleNote | models.CreateNoteMultiStatusResponse).validate_python(wire) + self.assertEqual(response.status_code, 201, "Note created but folder placement failed") + self.assertEqual(created.title, title) + self.assertIn("e2e", created.tags) + + raw = self.api.get_note(note_id, unwrap_data=False) + note = models.SingleNote.model_validate_json(raw.content) + self.assertEqual(note.id, note_id) + self.assertEqual(note.title, title) + self.assertIn("initial", note.content) + etag = raw.headers.get("ETag") + self.assertIsNotNone(etag) + conditional = self.api.get_note(note_id, etag=etag, unwrap_data=False) + self.assertEqual(conditional.status_code, 304) + self.assertEqual(conditional.content, b"") + + patch = self.api.update_note(note_id, models.UpdateNoteBody(title=title + "-patched", content="# patched\n\nbody", tags=["e2e", "updated"]), unwrap_data=False) + self.assertEqual(patch.status_code, 202) + changed = self.api.get_note(note_id, etag=etag, unwrap_data=False) + self.assertEqual(changed.status_code, 200) + note = models.SingleNote.model_validate_json(changed.content) + self.assertEqual(note.title, title + "-patched") + self.assertIn("patched", note.content) + self.assertTrue({"e2e", "updated"}.issubset(note.tags)) + + image = (ROOT / "../nodejs/tests/fixtures/hackmd-cute-logo.png").read_bytes() + uploaded = self.api.upload_note_image(note_id, image, filename=f"{title}.png") + self.assertTrue(uploaded.data.link) + found = next(note for note in self.api.list_notes() if note.id == note_id) + self.assertEqual(found.title, title + "-patched") + self.api.delete_note(note_id) + self.assertNotIn(note_id, [note.id for note in self.api.list_notes()]) + + @unittest.skipUnless(MUTATIONS and FOLDERS, "Folder writes disabled") + def test_folders_crud_nesting_and_order(self): + stamp = uuid4().hex + try: + response = self.api.create_folder(models.CreateUserFolderBody(name=f"e2e-python-parent-{stamp}", description="e2e parent"), unwrap_data=False) + except HttpResponseError as error: + if error.code == 404: + self.skipTest("Server does not expose folder writes") + raise + parent_id = response.json()["id"] + self.track(parent_id, self.api.delete_folder) + parent = models.ApiFolder.model_validate_json(response.content) + self.assertIn("e2e-python-parent", parent.name.root) + fetched = self.api.get_folder(parent_id) + self.assertEqual(fetched.id, parent_id) + self.assertEqual(fetched.description, "e2e parent") + renamed = f"e2e-python-renamed-{stamp}" + self.api.update_folder(parent_id, models.UpdateUserFolderBody(name=renamed, description="renamed")) + updated = self.api.get_folder(parent_id) + self.assertEqual(updated.name.root, renamed) + self.assertEqual(updated.description, "renamed") + child_response = self.api.create_folder(models.CreateUserFolderBody.model_validate({"name": f"e2e-python-child-{stamp}", "parentFolderId": parent_id}), unwrap_data=False) + child_id = child_response.json()["id"] + self.track(child_id, self.api.delete_folder) + self.assertEqual(self.api.get_folder(child_id).parent_folder_id, parent_id) + self.assertTrue({parent_id, child_id}.issubset(folder.id for folder in self.api.list_folders())) + + with self.subTest("folder order round-trip"): + try: + original = self.api.get_folder_order() + except HttpResponseError as error: + if error.code == 404: + self.skipTest("Server does not expose folder-order") + raise + order_dirty = False + def restore_order(): + nonlocal order_dirty + if order_dirty: + self.api.update_folder_order(models.UpdateFolderOrderBody(order=original)) + self.assertEqual(self.api.get_folder_order(), original) + order_dirty = False + # Registered last, runs before the folder cleanup callbacks. + self.addCleanup(restore_order) + root = list(dict.fromkeys([parent_id, *original.root.get("root", [])])) + order = models.ApiFolderOrder({**original.root, "root": root}) + order_dirty = True + self.api.update_folder_order(models.UpdateFolderOrderBody(order=order)) + self.assertIn(parent_id, self.api.get_folder_order().root["root"]) + restore_order() + + # Cleanup runs after assertions, in order: restore display order, child, + # parent. Verify deletes here too without leaving stale IDs in order. + self.api.delete_folder(child_id) + self.assertNotIn(child_id, [folder.id for folder in self.api.list_folders()]) + self.api.delete_folder(parent_id) + self.assertNotIn(parent_id, [folder.id for folder in self.api.list_folders()]) + + +class SafeResult(unittest.TextTestResult): + def addError(self, test, err): + if isinstance(err[1], ValidationError): + # Live note content/profile data must not enter failure logs. + details = "; ".join( + f"{'.'.join(map(str, item['loc']))}: {item['type']}" + for item in err[1].errors(include_input=False, include_context=False) + ) + err = (AssertionError, AssertionError(f"Response schema mismatch: {details}"), err[2]) + super().addError(test, err) + + +if __name__ == "__main__": + unittest.main(testRunner=unittest.TextTestRunner(verbosity=2, resultclass=SafeResult)) diff --git a/python/tests/smoke.py b/python/tests/smoke.py index 51f78dc..d8705de 100644 --- a/python/tests/smoke.py +++ b/python/tests/smoke.py @@ -86,7 +86,7 @@ def test_pydantic_body_and_multistatus(self): request = self.requests[0] self.assertEqual(request.method, "POST") self.assertEqual(request.headers["Content-Type"], "application/json") - self.assertEqual(json.loads(request.content), body.model_dump(mode="json", by_alias=True)) + self.assertEqual(json.loads(request.content), body.model_dump(mode="json", by_alias=True, exclude_unset=True)) self.assertEqual(json.loads(request.content)["parentFolderId"], "folder-1") self.assertEqual(response.status_code, 207) self.assertEqual(response.json(), result) From 6ee4bbe66e6e557339998457df69d1fcb36dfd23 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Mon, 28 Sep 2026 03:00:25 +0800 Subject: [PATCH 5/7] ci: verify Python client and simplify usage docs --- .github/workflows/python-ci.yml | 66 ++++++++++++++++++++++++ python/README.md | 89 +++++++++++---------------------- 2 files changed, 94 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..23afb04 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,66 @@ +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 + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.13'] + 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: ${{ matrix.python-version }} + 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 diff --git a/python/README.md b/python/README.md index e6525b4..fdab538 100644 --- a/python/README.md +++ b/python/README.md @@ -1,32 +1,23 @@ # Python API client experiment -This is a code generation experiment, not a supported or published Python API client. It reads the same vendored OpenAPI spec as the Node.js client and uses flat parameters. Generated files in `src/hackmd_api/generated/` belong in version control and must not be edited by hand. +An experimental Python API client generated from the same OpenAPI spec as the Node.js client. Not yet published or supported for production use. -Requires Node.js 22.18+, pnpm 10.33.2, Python 3.10+, and [uv](https://docs.astral.sh/uv/). From this directory: +## 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 -pnpm codegen -pnpm check:generated -pnpm check -pnpm test +uv sync --frozen ``` -The pinned generator includes a temporary [pnpm patch](./patches/README.md) adapting two pending upstream fixes. Use the commands above rather than `npx`, which bypasses the patch. - -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. - -`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. - -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. +## Usage -## Custom API - -Run local examples with `PYTHONPATH=src uv run python your_example.py`; this experiment is not yet packaged for installation. +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, models +from hackmd_api import API with API(os.environ["HACKMD_ACCESS_TOKEN"]) as api: notes = api.list_notes() @@ -34,65 +25,41 @@ with API(os.environ["HACKMD_ACCESS_TOKEN"]) as api: note = api.get_note(notes[0].id) print(note.title, note.content) - # All generated operations remain accessible; raw returns httpx.Response. + # Access any generated operation through api.raw. response = api.raw.list_webhooks() response.raise_for_status() ``` -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`. +`API` covers profile, teams, history, personal notes/images, personal folders/order, and team note detail. All generated operations are available through `api.raw`. -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": "..."})`. +- 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. -The following snippets belong inside the `with API(...) as api:` block above. +## Development -```python -# Mutating example: only run against an account you intend to modify. -created = api.create_note(models.CreateNote(title="Example", content="# Hello")) -if isinstance(created, models.CreateNoteMultiStatusResponse): - # HTTP 207 still created a note; do not retry the creation. - print(created.error) - note_id = created.note.id -else: - note_id = created.id - -api.update_note(note_id, models.UpdateNoteBody(title="Updated")) -# Unset fields are omitted; explicit None is serialized as JSON null. -api.update_note(note_id, models.UpdateNoteBody.model_validate({"parentFolderId": None})) -api.delete_note(note_id) +```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 ``` -`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. - -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. +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. -### ETag and raw responses - -```python -cached = api.get_note(note_id, unwrap_data=False) -cached_note = models.SingleNote.model_validate_json(cached.content) -etag = cached.headers.get("ETag") -note = api.get_note(note_id, etag=etag) -if note is None: # HTTP 304: keep your cached data - note = cached_note -``` - -`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. +Python CI runs generation checks, compilation, and offline tests on Python 3.10 and 3.13. It does not run live tests. ## Live E2E -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. - -From `python/`, reuse the ignored `nodejs/.env` (`HACKMD_ACCESS_TOKEN`, optional `HACKMD_API_ENDPOINT`). Real environment variables override values in that file: +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 # creates/deletes notes, folders; uploads an image +HACKMD_E2E_MUTATIONS=0 pnpm test:e2e # read-only +HACKMD_E2E_MUTATIONS=1 pnpm test:e2e # note/folder CRUD, image upload, and ETag ``` -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. - -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. - -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. +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. -Grouped parameters, async wrappers, packaging/publication, and wrappers for all operations remain outside this first slice. +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. From 1a69ee0f3ff35b794e44605944b44b3fed5219b9 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Mon, 28 Sep 2026 03:13:35 +0800 Subject: [PATCH 6/7] ci: run Python checks on 3.13 only --- .github/workflows/python-ci.yml | 6 +----- python/README.md | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 23afb04..4c38d97 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -19,10 +19,6 @@ permissions: jobs: test: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ['3.10', '3.13'] defaults: run: working-directory: python @@ -46,7 +42,7 @@ jobs: uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: version: '0.12.18' - python-version: ${{ matrix.python-version }} + python-version: '3.13' working-directory: python cache-dependency-glob: python/uv.lock diff --git a/python/README.md b/python/README.md index fdab538..6a3a16f 100644 --- a/python/README.md +++ b/python/README.md @@ -49,7 +49,7 @@ 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.10 and 3.13. It does not run live tests. +Python CI runs generation checks, compilation, and offline tests on Python 3.13. It does not run live tests. ## Live E2E From 063182d508f2412bd1ed6223111329a1a684f387 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Mon, 28 Sep 2026 23:19:54 +0800 Subject: [PATCH 7/7] fix: sync Python nullable note description contract --- python/README.md | 1 + python/src/hackmd_api/generated/pydantic_gen.py | 4 ++-- python/src/hackmd_api/generated/sdk_gen.py | 4 ++-- python/tests/api.py | 10 ++++++++++ python/tests/smoke.py | 13 +++++++++++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/python/README.md b/python/README.md index 6a3a16f..0462689 100644 --- a/python/README.md +++ b/python/README.md @@ -37,6 +37,7 @@ with API(os.environ["HACKMD_ACCESS_TOKEN"]) as api: - 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. +- Clear a note description with `api.update_note(note_id, models.UpdateNoteBody(description=None))`; omitting the field leaves it unchanged. ## Development diff --git a/python/src/hackmd_api/generated/pydantic_gen.py b/python/src/hackmd_api/generated/pydantic_gen.py index 0591fcf..3efdaaa 100644 --- a/python/src/hackmd_api/generated/pydantic_gen.py +++ b/python/src/hackmd_api/generated/pydantic_gen.py @@ -1158,7 +1158,7 @@ class GetNoteResponse(RootModel[SingleNote]): class UpdateNoteBody(BaseModel): - """The properties to update on the note.""" + """The properties to update on the note. Omit description to leave it unchanged, use a string (including an empty string) to set it, or null to remove its metadata.""" parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") permalink: Optional[str] = None @@ -1328,7 +1328,7 @@ class GetTeamNoteResponse(RootModel[SingleNote]): class UpdateTeamNoteBody(BaseModel): - """The properties to update on the team note (e.g., content, permissions, permalink, parentFolderId).""" + """The properties to update on the team note. Omit description to leave it unchanged, use a string (including an empty string) to set it, or null to remove its metadata.""" parent_folder_id: Optional[str] = Field(default=None, alias="parentFolderId") permalink: Optional[str] = None diff --git a/python/src/hackmd_api/generated/sdk_gen.py b/python/src/hackmd_api/generated/sdk_gen.py index 0004088..f46af39 100644 --- a/python/src/hackmd_api/generated/sdk_gen.py +++ b/python/src/hackmd_api/generated/sdk_gen.py @@ -213,7 +213,7 @@ def update_note( writePermission: Optional[NotePermissionRole] = None, readPermission: Optional[NotePermissionRole] = None, content: Optional[str] = None, - description: Optional[str] = None, + description: Optional[Any] = None, tags: Optional[list[str]] = None, title: Optional[str] = None, request_overrides: Optional[dict[str, Any]] = None, @@ -346,7 +346,7 @@ def update_team_note( writePermission: Optional[NotePermissionRole] = None, readPermission: Optional[NotePermissionRole] = None, content: Optional[str] = None, - description: Optional[str] = None, + description: Optional[Any] = None, tags: Optional[list[str]] = None, title: Optional[str] = None, request_overrides: Optional[dict[str, Any]] = None, diff --git a/python/tests/api.py b/python/tests/api.py index 7e71a17..af7c2d7 100644 --- a/python/tests/api.py +++ b/python/tests/api.py @@ -79,6 +79,16 @@ def test_patch_preserves_null_and_omits_unset(self): self.api.update_folder("folder-1", models.UpdateUserFolderBody.model_validate({"description": None})) self.assertEqual(json.loads(self.requests[-1].content), {"description": None}) + def test_note_description_omitted_string_and_null(self): + for fields in [{}, {"description": "Changed"}, {"description": ""}, {"description": None}]: + with self.subTest(fields=fields): + self.respond(202) + body = models.UpdateNoteBody.model_validate(fields) + self.assertIsNone(self.api.update_note("note-1", body)) + self.assertEqual(json.loads(self.requests[-1].content), fields) + with self.assertRaises(ValidationError): + models.SingleNote.model_validate({**NOTE, "description": None}) + def test_multipart_is_bytes_not_json(self): self.respond(201, {"data": {"link": "https://example.test/image.png"}}) result = self.api.upload_note_image("note-1", b"\x89PNG\x00\xff", filename="test.png") diff --git a/python/tests/smoke.py b/python/tests/smoke.py index d8705de..f15b818 100644 --- a/python/tests/smoke.py +++ b/python/tests/smoke.py @@ -91,6 +91,19 @@ def test_pydantic_body_and_multistatus(self): self.assertEqual(response.status_code, 207) self.assertEqual(response.json(), result) + def test_team_note_description_omitted_string_and_null(self): + self.response = httpx.Response(202) + for fields in [{}, {"description": "Changed"}, {"description": ""}, {"description": None}]: + with self.subTest(fields=fields): + body = models.UpdateTeamNoteBody.model_validate(fields) + response = self.sdk.update_team_note( + teampath="docs", noteId="note-1", request_overrides={"json": body}, + ) + self.assertEqual(self.requests[-1].method, "PATCH") + self.assertEqual(self.requests[-1].url.path, "/custom/v1/teams/docs/notes/note-1") + self.assertEqual(json.loads(self.requests[-1].content), fields) + self.assertEqual(response.status_code, 202) + def test_no_content_conditional_request_and_error_response(self): self.response = httpx.Response(204) response = self.sdk.delete_note(noteId="note-1")