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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ that call only:
client.search_slots(state="MA", timeout=10, max_retries=0)
```

An operation that declares a header of its own takes it as one more keyword, named from the header
and typed from the spec. `process_message` takes `payer_authorization`, sent as
`Payer-Authorization`: a member's own authorization at their insurer, `Bearer` and the access token
your app got from the member's login there. The SDK checks it against the form the API takes before
sending, refuses one the API would not take without repeating it, and holds it where nothing prints
it, a traceback's local variables included.

```python
checked = client.process_message(message, payer_authorization=f"Bearer {access_token}")
```

`http_client` passes anything [httpx2](https://github.com/pydantic/httpx2) takes: a proxy,
certificates, limits, event hooks, a transport. The SDK's own method, URL, headers, body and
timeout always win, it never follows a redirect, and it never closes a client you pass.
Expand Down
3 changes: 3 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ snake_case (`name:contains` is `name_contains`, `_count` is `count`); each metho
both. Every method also takes the per-call options `timeout`, `max_retries`, `correlation_id` and
`extra_headers`, and `client.with_response.<method>(...)` gives the request id and timing too.

An operation that declares a header takes it as one more keyword, named from the header in snake_case and
checked before sending: `process_message` takes `payer_authorization`, sent as `Payer-Authorization`.

`batch()` sends several of these as one request, each with its own answer or error, from the entries
`haau3.calls` makes. It takes every operation on the FHIR base except `fhir_batch`, `process_message` and `reconcile_medications`: 29 of the
37. See the README's "Several requests in one".
Expand Down
108 changes: 101 additions & 7 deletions scripts/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -89,6 +90,17 @@ class Param:
description: str


@dataclass
class Header:
"""An optional header parameter, sent from a keyword of the call's own named from it."""

wire: str
python: str
description: str
max_length: int | None
pattern: str | None


@dataclass
class Op:
id: str
Expand All @@ -103,6 +115,7 @@ class Op:
body: str | None
response: str
expects: list[str]
headers: list[Header] = field(default_factory=list)
batchable: bool = False
name: str = field(init=False)
const: str = field(init=False)
Expand Down Expand Up @@ -202,6 +215,36 @@ def _query_type(self, op_id: str, param: Json) -> str:
f"{op_id}: the query parameter {param.get('name')} has a schema the generator does not read: {schema}"
)

@staticmethod
def _headers(op_id: str, params: list[Json]) -> list[Header]:
"""The optional header parameters. A required one, or a parameter in a cookie, stops the generator."""
headers: list[Header] = []
for p in params:
if p["in"] not in ("path", "query", "header"):
raise ValueError(f"{op_id}: a parameter in {p['in']}, which the generator does not send")
if p["in"] != "header":
continue
if p.get("required"):
raise ValueError(
f"{op_id}: the required header {p['name']}, which a generated method cannot complete without"
)
schema = p.get("schema") or {}
if schema.get("type") != "string":
raise ValueError(f"{op_id}: the header {p['name']} is not a string, the only kind the generator sends")
pattern = schema.get("pattern")
if pattern is not None:
re.compile(pattern) # The spec's ECMAScript pattern, which this one must read as Python does.
headers.append(
Header(
wire=p["name"],
python=naming.python_name(p["name"]),
description=" ".join((p.get("description") or "").split()),
max_length=schema.get("maxLength"),
pattern=pattern,
)
)
return headers

def _operations(self) -> list[Op]:
docs = naming.DocsUrls()
ops: list[Op] = []
Expand All @@ -222,10 +265,11 @@ def _operations(self) -> list[Op]:
for p in params
if p["in"] == "query"
]
names = [p.python for p in query]
headers = self._headers(op["operationId"], params)
names = [p.python for p in query] + [h.python for h in headers]
clashes = {n for n in names if names.count(n) > 1} | (set(names) & {*OPTION_NAMES, "self", "body"})
if clashes:
raise ValueError(f"{op['operationId']}: query parameters clash as {sorted(clashes)}")
raise ValueError(f"{op['operationId']}: parameters clash as {sorted(clashes)}")
tag = (op.get("tags") or ["Other"])[0]
ops.append(
Op(
Expand All @@ -241,13 +285,19 @@ def _operations(self) -> list[Op]:
body=self._body(op),
response=self._response(op),
expects=self._expects(op),
headers=headers,
)
)
for op in ops:
if op.method not in ("GET", "POST"):
raise ValueError(f"{op.id} is {op.method}; the SDK sends GET and POST only")
for op in ops:
mark = self.spec["paths"][op.path][op.method.lower()].get(BATCHABLE)
if op.headers and op.path.startswith("/v1/fhir/r4/") and mark is not False:
# A batch entry is one request inside the batch's body, which carries no headers of its own.
raise ValueError(
f"{op.id} declares the header {op.headers[0].wire} and can go in a batch, whose entries carry no headers"
)
if mark is not None and mark is not False:
raise ValueError(f"{op.id} carries {BATCHABLE}: {mark!r}, where the spec defines only false")
if mark is False and not op.path.startswith("/v1/fhir/r4/"):
Expand Down Expand Up @@ -275,6 +325,7 @@ def _signature(op: Op, *, for_call: bool = False) -> str:
if positional:
parts.append("/")
keywords = [f"{p.python}: {p.type}" if p.required else f"{p.python}: {p.type} | None = None" for p in op.query]
keywords += [f"{h.python}: str | None = None" for h in op.headers]
if not for_call:
keywords += [
"timeout: float | None = None",
Expand Down Expand Up @@ -305,6 +356,16 @@ def _method_doc(op: Op) -> str:
required = " Required." if p.required else ""
lines.append(f"- `{p.python}`{wire}:{required} {p.description}".rstrip())
parts.append("\n".join(lines))
if op.headers:
lines = ["Headers it declares, each a keyword of its own:", ""]
for h in op.headers:
limit = f", at most {h.max_length} characters" if h.max_length is not None else ""
lines.append(
f"- `{h.python}` (`{h.wire}`): {h.description} Checked against the form the API takes for it{limit},"
f" before sending; a value it does not take is refused without being repeated, and the keyword wins over"
f" a `{h.wire}` in `extra_headers`. Held from the moment the call takes it where nothing prints it.".strip()
)
parts.append("\n".join(lines))
return "\n\n".join(parts)


Expand All @@ -313,6 +374,19 @@ def _hide_body(op: Op, indent: str) -> list[str]:
return [f"{indent}hidden: Hidden[object] = Hidden(body)", f"{indent}del body"] if op.body else []


def _hide_headers(op: Op, indent: str) -> list[str]:
# A declared header can carry a credential, such as a member's authorization: held at once, the same way.
if not op.headers:
return []
values = ", ".join(f"{h.python!r}: {h.python}" for h in op.headers)
return [f"{indent}declared = HeldHeaders({{{values}}})", f"{indent}del {', '.join(h.python for h in op.headers)}"]


def _options(op: Op) -> str:
held = ", declared" if op.headers else ""
return f"Options(timeout, max_retries, correlation_id, extra_headers{held})"


def _methods_class(ops: list[Op], *, name: str, asynchronous: bool, with_response: bool) -> str:
client = "AsyncHaau3" if asynchronous else "Haau3"
engine = "AsyncEngine" if asynchronous else "SyncEngine"
Expand Down Expand Up @@ -352,7 +426,8 @@ def _methods_class(ops: list[Op], *, name: str, asynchronous: bool, with_respons
else:
lines.append(docstring(_method_doc(op), " "))
lines += _hide_body(op, " ")
call = f"self._engine.call({_call_arguments(op, 'hidden')}, Options(timeout, max_retries, correlation_id, extra_headers))"
lines += _hide_headers(op, " ")
call = f"self._engine.call({_call_arguments(op, 'hidden')}, {_options(op)})"
if asynchronous:
call = f"(await {call})"
lines.append(f" return {call}{'' if with_response else '.data'}")
Expand Down Expand Up @@ -430,7 +505,7 @@ def methods_module(spec: Spec) -> str:
"from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload\n",
"from . import fhir",
"from ._batch import BatchResult, Call",
"from ._body import Hidden",
"from ._body import HeldHeaders, Hidden",
f"from ._components import {', '.join(own)}",
f"from ._operations import {', '.join(o.const for o in ops)}",
"from ._response import Options, Response\n",
Expand Down Expand Up @@ -461,13 +536,21 @@ def operations_module(spec: Spec) -> str:
"from typing import Final, Union\n",
"from . import fhir",
f"from ._components import {', '.join(own)}",
"from ._operation import Operation\n",
"from ._operation import DeclaredHeader, Operation\n",
f"SPEC_VERSION: Final[str] = {spec.spec['info']['version']!r}",
'"""The version of the API\'s spec this SDK was generated from."""\n',
]
for op in spec.ops:
expects = "(" + "".join(f"{t!r}, " for t in op.expects) + ")"
lines.append(f'{op.const}: Final[Operation[{op.response}]] = Operation("{op.method}", {op.path!r}, {expects})')
headers = ""
if op.headers:
declared = "".join(
f"DeclaredHeader({h.wire!r}, {h.python!r}, {h.max_length!r}, {h.pattern!r}), " for h in op.headers
)
headers = f", headers=({declared})"
lines.append(
f'{op.const}: Final[Operation[{op.response}]] = Operation("{op.method}", {op.path!r}, {expects}{headers})'
)
return "\n".join(lines) + "\n"


Expand Down Expand Up @@ -554,6 +637,17 @@ def _readable_signature(op: Op) -> str:
return f"{op.name}({', '.join(args)})"


def _header_keywords(spec: Spec) -> str:
"""What api.md says of the keywords an operation's declared headers add."""
each = [f"`{op.name}` takes `{h.python}`, sent as `{h.wire}`" for op in spec.ops for h in op.headers]
if not each:
return ""
return (
"\nAn operation that declares a header takes it as one more keyword, named from the header in snake_case and\n"
f"checked before sending: {'; '.join(each)}.\n"
)


def api_md(spec: Spec) -> str:
tags = [t["name"] for t in spec.spec.get("tags", [])]
text = {t["name"]: t.get("description", "") for t in spec.spec.get("tags", [])}
Expand Down Expand Up @@ -583,7 +677,7 @@ def api_md(spec: Spec) -> str:
snake_case (`name:contains` is `name_contains`, `_count` is `count`); each method's docstring gives
both. Every method also takes the per-call options `timeout`, `max_retries`, `correlation_id` and
`extra_headers`, and `client.with_response.<method>(...)` gives the request id and timing too.

{_header_keywords(spec)}
`batch()` sends several of these as one request, each with its own answer or error, from the entries
`haau3.calls` makes. It takes every operation on the FHIR base except {excluded}: {len(batchable)} of the
{len(spec.ops)}. See the README's "Several requests in one".
Expand Down
6 changes: 4 additions & 2 deletions scripts/readme_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
Blocks are kept in order, so a later one uses what an earlier one made (`client`). A block that
awaits goes into `readme_async`, the others into `readme_sync`. A block after the line
`<!-- not run: <why> -->` is type-checked only, in `readme_not_run`. The names the README leaves to
the reader (`plan_id`, `records`, `message`, `codes`, `job_id`) are the functions' parameters.
the reader (`plan_id`, `records`, `message`, `codes`, `job_id`, `access_token`) are the functions'
parameters.
"""

from __future__ import annotations
Expand All @@ -25,7 +26,8 @@
_AWAITS = re.compile(r"\bawait\b|\basync (for|with)\b")

PARAMETERS = (
"plan_id: str, records: ReconcileMedicationsBody, message: ProcessMessageBody, codes: list[str], job_id: str"
"plan_id: str, records: ReconcileMedicationsBody, message: ProcessMessageBody, codes: list[str], job_id: str,"
" access_token: str"
)


Expand Down
28 changes: 27 additions & 1 deletion src/haau3/_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
patient's data. So from the moment the SDK reads a body until it hands the answer back, the body
lives in a `Hidden`: its repr is `<body hidden>`, so a traceback that shows each frame's local
variables (`traceback`'s `capture_locals`, or an error reporter that records them) shows that and
nothing else. The same goes for a request body, and for the key in a `Secret`.
nothing else. The same goes for a request body, for the key in a `Secret`, and for the headers a
call's own options carry, such as a member's authorization, in `HeldHeaders`.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Generic, NoReturn, TypeVar, final

T_co = TypeVar("T_co", covariant=True)
Expand Down Expand Up @@ -38,6 +40,30 @@ def __reduce__(self) -> NoReturn:
raise TypeError("a hidden body is never pickled or copied")


@final
class HeldHeaders:
"""Headers a call's own options carry, by option or by header name: printed as `<headers hidden>`, never pickled."""

__slots__ = ("_values",)

def __init__(self, values: Mapping[str, str | None]) -> None:
self._values = dict(values)

def reveal(self) -> dict[str, str | None]:
return dict(self._values)

def __repr__(self) -> str:
return "<headers hidden>"

__str__ = __repr__

def __format__(self, format_spec: str) -> str:
return "<headers hidden>"

def __reduce__(self) -> NoReturn:
raise TypeError("held headers are never pickled or copied")


@final
class Secret:
"""The API key: its repr, str and format are `<key hidden>`, and it is never pickled."""
Expand Down
Loading
Loading