anvisa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
anvisa/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Client for ANVISA's official Consultas Externas API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .auth import Credentials # noqa: E402 (client.py reads __version__ at import time)
6
+ from .client import Client # noqa: E402
7
+ from .errors import ( # noqa: E402
8
+ AnvisaError,
9
+ ApiError,
10
+ AuthError,
11
+ BlockedError,
12
+ CredentialsError,
13
+ InvalidPageError,
14
+ MalformedRequestError,
15
+ MissingFilterError,
16
+ NotFoundError,
17
+ RateLimitError,
18
+ RequestRejectedError,
19
+ )
20
+ from .throttle import Throttle # noqa: E402
21
+
22
+ __all__ = [
23
+ "AnvisaError",
24
+ "ApiError",
25
+ "AuthError",
26
+ "BlockedError",
27
+ "Client",
28
+ "Credentials",
29
+ "CredentialsError",
30
+ "InvalidPageError",
31
+ "MalformedRequestError",
32
+ "MissingFilterError",
33
+ "NotFoundError",
34
+ "RateLimitError",
35
+ "RequestRejectedError",
36
+ "Throttle",
37
+ "__version__",
38
+ ]
anvisa/auth.py ADDED
@@ -0,0 +1,119 @@
1
+ """Credentials and the OAuth2 client-credentials flow against ANVISA's Keycloak.
2
+
3
+ Tokens last 1740 s and there is no refresh token, so `TokenAuth` simply requests a new
4
+ one when the cached token is within `margin` seconds of expiring, or after a 401.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from collections.abc import Callable, Generator, Mapping
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+
17
+ from .errors import AuthError, CredentialsError
18
+
19
+ TOKEN_URL = (
20
+ "https://acesso.prd.apps.anvisa.gov.br/auth/realms/externo/protocol/openid-connect/token"
21
+ )
22
+ CREDENTIALS_FILE = Path("~/.config/anvisa/credentials.env")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Credentials:
27
+ client_id: str
28
+ client_secret: str
29
+ token_url: str = TOKEN_URL
30
+
31
+ @classmethod
32
+ def from_env(
33
+ cls,
34
+ env: Mapping[str, str] | None = None,
35
+ path: Path = CREDENTIALS_FILE,
36
+ ) -> Credentials:
37
+ """From ANVISA_CLIENT_ID/ANVISA_CLIENT_SECRET, else CLIENT_ID=/CLIENT_SECRET= in `path`."""
38
+ env = os.environ if env is None else env
39
+ file_values = read_env_file(path.expanduser())
40
+ client_id = env.get("ANVISA_CLIENT_ID") or file_values.get("CLIENT_ID")
41
+ client_secret = env.get("ANVISA_CLIENT_SECRET") or file_values.get("CLIENT_SECRET")
42
+ if not (client_id and client_secret):
43
+ raise CredentialsError(
44
+ "no ANVISA credentials: set ANVISA_CLIENT_ID and ANVISA_CLIENT_SECRET, "
45
+ f"or write CLIENT_ID=... and CLIENT_SECRET=... to {path} (chmod 600). "
46
+ "Credentials come from https://api.anvisa.gov.br/ (login Gov.br)."
47
+ )
48
+ return cls(client_id, client_secret)
49
+
50
+ def __repr__(self) -> str: # never print the secret
51
+ return f"Credentials(client_id={self.client_id!r}, client_secret='***')"
52
+
53
+
54
+ def read_env_file(path: Path) -> dict[str, str]:
55
+ """Parse `KEY=VALUE` lines (comments, blank lines, `export` and quotes allowed)."""
56
+ if not path.is_file():
57
+ return {}
58
+ values: dict[str, str] = {}
59
+ for raw in path.read_text(encoding="utf-8").splitlines():
60
+ line = raw.strip()
61
+ if not line or line.startswith("#") or "=" not in line:
62
+ continue
63
+ key, _, value = line.removeprefix("export ").partition("=")
64
+ values[key.strip()] = value.strip().strip("'\"")
65
+ return values
66
+
67
+
68
+ class TokenAuth(httpx.Auth):
69
+ """httpx auth hook: fetches and caches the bearer token, retries once on 401."""
70
+
71
+ requires_response_body = True
72
+
73
+ def __init__(
74
+ self,
75
+ credentials: Credentials,
76
+ *,
77
+ margin: float = 60.0,
78
+ clock: Callable[[], float] = time.time,
79
+ ) -> None:
80
+ self.credentials = credentials
81
+ self.margin = margin
82
+ self._clock = clock
83
+ self._token: str | None = None
84
+ self._expires_at = 0.0
85
+
86
+ @property
87
+ def token_valid(self) -> bool:
88
+ return self._token is not None and self._clock() < self._expires_at - self.margin
89
+
90
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
91
+ if not self.token_valid:
92
+ yield from self._fetch_token(request)
93
+ request.headers["Authorization"] = f"Bearer {self._token}"
94
+ response = yield request
95
+ if response.status_code == 401:
96
+ yield from self._fetch_token(request)
97
+ request.headers["Authorization"] = f"Bearer {self._token}"
98
+ yield request
99
+
100
+ def _fetch_token(
101
+ self, original: httpx.Request
102
+ ) -> Generator[httpx.Request, httpx.Response, None]:
103
+ token_request = httpx.Request(
104
+ "POST",
105
+ self.credentials.token_url,
106
+ data={
107
+ "grant_type": "client_credentials",
108
+ "client_id": self.credentials.client_id,
109
+ "client_secret": self.credentials.client_secret,
110
+ },
111
+ # the token endpoint sits behind the same Cloudflare rules: reuse the caller's UA
112
+ headers={"User-Agent": original.headers.get("User-Agent", "anvisa-python")},
113
+ )
114
+ response = yield token_request
115
+ if response.status_code != 200:
116
+ raise AuthError(f"token request failed: HTTP {response.status_code}")
117
+ body = response.json()
118
+ self._token = body["access_token"]
119
+ self._expires_at = self._clock() + float(body.get("expires_in", 0))
anvisa/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """`anvisa` command line: fila de análise, UDI and assunto lookups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+ import typer
14
+ from pydantic import BaseModel
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from . import __version__
19
+ from .client import Client
20
+ from .errors import AnvisaError, CredentialsError
21
+
22
+ try:
23
+ from zoneinfo import ZoneInfo
24
+
25
+ BRT = ZoneInfo("America/Sao_Paulo")
26
+ except Exception: # no tz database available
27
+ BRT = None
28
+
29
+
30
+ class Format(str, Enum):
31
+ json = "json"
32
+ table = "table"
33
+
34
+
35
+ app = typer.Typer(
36
+ help="ANVISA Consultas Externas: fila de análise, UDI de dispositivos médicos e assuntos.",
37
+ no_args_is_help=True,
38
+ )
39
+ fila_app = typer.Typer(
40
+ help="Fila de análise (posição dos processos por subfila).", no_args_is_help=True
41
+ )
42
+ udi_app = typer.Typer(help="UDI de dispositivos médicos e termos GMDN.", no_args_is_help=True)
43
+ assunto_app = typer.Typer(
44
+ help="Assuntos de peticionamento (documentos, formulários, taxas).", no_args_is_help=True
45
+ )
46
+ app.add_typer(fila_app, name="fila")
47
+ app.add_typer(udi_app, name="udi")
48
+ app.add_typer(assunto_app, name="assunto")
49
+
50
+
51
+ def _version(value: bool) -> None:
52
+ if value:
53
+ typer.echo(f"anvisa {__version__}")
54
+ raise typer.Exit()
55
+
56
+
57
+ @app.callback()
58
+ def main(
59
+ ctx: typer.Context,
60
+ format: Format | None = typer.Option(
61
+ None,
62
+ "--format",
63
+ "-f",
64
+ help="json or table (default: table on a terminal, json when piped).",
65
+ ),
66
+ version: bool = typer.Option(
67
+ False, "--version", callback=_version, is_eager=True, help="Print the version and exit."
68
+ ),
69
+ ) -> None:
70
+ ctx.obj = format or (Format.table if sys.stdout.isatty() else Format.json)
71
+
72
+
73
+ def make_client() -> Client:
74
+ """Separate so tests can swap in a client with a mocked transport."""
75
+ return Client.from_env()
76
+
77
+
78
+ @contextmanager
79
+ def handle_errors() -> Iterator[None]:
80
+ try:
81
+ yield
82
+ except CredentialsError as exc:
83
+ typer.echo(f"error: {exc}", err=True)
84
+ raise typer.Exit(2) from None
85
+ except AnvisaError as exc:
86
+ typer.echo(f"error: {exc}", err=True)
87
+ raise typer.Exit(1) from None
88
+
89
+
90
+ def emit(ctx: typer.Context, data: BaseModel | list[BaseModel], title: str = "") -> None:
91
+ rows = data if isinstance(data, list) else [data]
92
+ console = Console() # created per call so COLUMNS/TTY are read at run time
93
+ if ctx.obj == Format.json:
94
+ payload = [r.model_dump(mode="json") for r in rows]
95
+ typer.echo(
96
+ json.dumps(
97
+ payload if isinstance(data, list) else payload[0], ensure_ascii=False, indent=2
98
+ )
99
+ )
100
+ return
101
+ if not rows:
102
+ console.print("(nenhum resultado)")
103
+ return
104
+ if isinstance(data, list):
105
+ table = Table(title=title or None)
106
+ columns = list(rows[0].model_dump())
107
+ for column in columns:
108
+ table.add_column(column)
109
+ for row in rows:
110
+ values = row.model_dump()
111
+ table.add_row(*(render(values[c]) for c in columns))
112
+ else:
113
+ table = Table(title=title or None, show_header=False)
114
+ for key, value in rows[0].model_dump().items():
115
+ table.add_row(key, render(value))
116
+ console.print(table)
117
+
118
+
119
+ def render(value: Any) -> str:
120
+ if value is None:
121
+ return ""
122
+ if isinstance(value, datetime):
123
+ local = value.astimezone(BRT) if BRT else value
124
+ return (
125
+ local.strftime("%Y-%m-%d")
126
+ if (local.hour, local.minute) == (0, 0)
127
+ else local.strftime("%Y-%m-%d %H:%M")
128
+ )
129
+ if isinstance(value, Enum):
130
+ return str(value.value)
131
+ if isinstance(value, dict | list):
132
+ return json.dumps(value, ensure_ascii=False, default=str)
133
+ return str(value)
134
+
135
+
136
+ # --- fila -------------------------------------------------------------------
137
+
138
+
139
+ @fila_app.command("areas")
140
+ def fila_areas(ctx: typer.Context) -> None:
141
+ """Áreas de interesse (Medicamento=1, Dispositivos Médicos=8, ...)."""
142
+ with handle_errors(), make_client() as client:
143
+ emit(ctx, client.fila.areas(), "Áreas")
144
+
145
+
146
+ @fila_app.command("grupos")
147
+ def fila_grupos(
148
+ ctx: typer.Context, area_id: int = typer.Argument(help="id from `anvisa fila areas`")
149
+ ) -> None:
150
+ """Grupos de fila de uma área."""
151
+ with handle_errors(), make_client() as client:
152
+ emit(ctx, client.fila.grupos(area_id), f"Grupos da área {area_id}")
153
+
154
+
155
+ @fila_app.command("subfilas")
156
+ def fila_subfilas(
157
+ ctx: typer.Context, grupo_id: int = typer.Argument(help="id from `anvisa fila grupos`")
158
+ ) -> None:
159
+ """Subfilas de um grupo."""
160
+ with handle_errors(), make_client() as client:
161
+ emit(ctx, client.fila.subfilas(grupo_id), f"Subfilas do grupo {grupo_id}")
162
+
163
+
164
+ @fila_app.command("consulta")
165
+ def fila_consulta(
166
+ ctx: typer.Context, subfila_id: int = typer.Argument(help="id from `anvisa fila subfilas`")
167
+ ) -> None:
168
+ """A fila calculada completa de uma subfila, em ordem."""
169
+ with handle_errors(), make_client() as client:
170
+ emit(ctx, client.fila.consulta(subfila_id), f"Fila da subfila {subfila_id}")
171
+
172
+
173
+ # --- udi --------------------------------------------------------------------
174
+
175
+
176
+ @udi_app.command("search")
177
+ def udi_search(
178
+ ctx: typer.Context,
179
+ nome: str | None = typer.Option(None, "--nome", "-n", help="nomeComercial (verified filter)"),
180
+ udi_di: str | None = typer.Option(None, "--udi-di", help="udiDi (unverified)"),
181
+ cnpj: str | None = typer.Option(None, "--cnpj", help="cnpjDetentora (unverified)"),
182
+ gmdn: str | None = typer.Option(None, "--gmdn", help="codigoGmdn (unverified)"),
183
+ registro: str | None = typer.Option(None, "--registro", help="nuRegistro (unverified)"),
184
+ page: int = typer.Option(1, "--page", min=1),
185
+ size: int = typer.Option(20, "--size", min=1),
186
+ all_pages: bool = typer.Option(
187
+ False, "--all", help="iterate every page (respects the rate limit)"
188
+ ),
189
+ ) -> None:
190
+ """Busca de dispositivos por UDI. Pelo menos um filtro é obrigatório."""
191
+ filters = {
192
+ k: v
193
+ for k, v in {
194
+ "nomeComercial": nome,
195
+ "udiDi": udi_di,
196
+ "cnpjDetentora": cnpj,
197
+ "codigoGmdn": gmdn,
198
+ "nuRegistro": registro,
199
+ }.items()
200
+ if v
201
+ }
202
+ with handle_errors(), make_client() as client:
203
+ if all_pages:
204
+ emit(ctx, list(client.udi.iter_search(size=size, **filters)), "UDI")
205
+ else:
206
+ result = client.udi.search(page=page, size=size, **filters)
207
+ total = f" (página {page} de {result.totalPages}, {result.totalElements} no total)"
208
+ emit(ctx, result.content or [], "UDI" + total)
209
+
210
+
211
+ @udi_app.command("get")
212
+ def udi_get(ctx: typer.Context, id: int) -> None:
213
+ """Detalhe de um dispositivo (id interno, de `udi search`)."""
214
+ with handle_errors(), make_client() as client:
215
+ emit(ctx, client.udi.get(id), f"UDI {id}")
216
+
217
+
218
+ @udi_app.command("historico")
219
+ def udi_historico(ctx: typer.Context, id_dispositivo: int, id_historico: int) -> None:
220
+ """Uma versão histórica de um dispositivo."""
221
+ with handle_errors(), make_client() as client:
222
+ emit(ctx, client.udi.get_historico(id_dispositivo, id_historico), "UDI (histórico)")
223
+
224
+
225
+ @udi_app.command("gmdn")
226
+ def udi_gmdn(ctx: typer.Context, codigo: str) -> None:
227
+ """Termo GMDN por código."""
228
+ with handle_errors(), make_client() as client:
229
+ emit(ctx, client.udi.termo_gmdn(codigo), f"GMDN {codigo}")
230
+
231
+
232
+ # --- assunto ----------------------------------------------------------------
233
+
234
+
235
+ @assunto_app.command("lista")
236
+ def assunto_lista(
237
+ ctx: typer.Context,
238
+ busca: str | None = typer.Option(None, "--busca", "-b", help="filter locally by text"),
239
+ ) -> None:
240
+ """Todos os códigos de assunto de peticionamento (uma requisição, ~2.600 linhas)."""
241
+ with handle_errors(), make_client() as client:
242
+ rows = client.assunto.lista()
243
+ if busca:
244
+ rows = [r for r in rows if busca.lower() in (r.descricao or "").lower()]
245
+ emit(ctx, rows, "Assuntos")
246
+
247
+
248
+ @assunto_app.command("get")
249
+ def assunto_get(ctx: typer.Context, codigo: int) -> None:
250
+ """Detalhe de um assunto: sistema, serviços, formulários, checklist e taxas por porte."""
251
+ with handle_errors(), make_client() as client:
252
+ emit(ctx, client.assunto.detalhe(codigo), f"Assunto {codigo}")
anvisa/client.py ADDED
@@ -0,0 +1,231 @@
1
+ """The HTTP client and the API domains: `fila`, `udi` and `assunto`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from typing import Any, Protocol, TypeVar
7
+
8
+ import httpx
9
+
10
+ from . import __version__, models
11
+ from .auth import Credentials, TokenAuth
12
+ from .errors import InvalidPageError, MissingFilterError, raise_for_response
13
+ from .throttle import Throttle
14
+
15
+ BASE_URL = "https://api-gateway.prd.apps.anvisa.gov.br/consultas-externas-api"
16
+ USER_AGENT = f"anvisa-python/{__version__} (+https://github.com/vasfvitor/anvisa-api)"
17
+
18
+
19
+ class Client:
20
+ """Sync client. Use as a context manager or call `close()`.
21
+
22
+ >>> with Client.from_env() as anvisa:
23
+ ... anvisa.fila.areas()
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ credentials: Credentials | None = None,
29
+ *,
30
+ user_agent: str = USER_AGENT,
31
+ base_url: str = BASE_URL,
32
+ timeout: float = 30.0,
33
+ transport: httpx.BaseTransport | None = None,
34
+ throttle: Throttle | None = None,
35
+ ) -> None:
36
+ self.credentials = credentials or Credentials.from_env()
37
+ self.throttle = throttle or Throttle()
38
+ self._http = httpx.Client(
39
+ base_url=base_url,
40
+ timeout=timeout,
41
+ transport=transport,
42
+ headers={"User-Agent": user_agent, "Accept": "application/json"},
43
+ auth=TokenAuth(self.credentials),
44
+ )
45
+ self.fila = Fila(self)
46
+ self.udi = Udi(self)
47
+ self.assunto = Assunto(self)
48
+
49
+ @classmethod
50
+ def from_env(cls, **kwargs: Any) -> Client:
51
+ return cls(Credentials.from_env(), **kwargs)
52
+
53
+ def close(self) -> None:
54
+ self._http.close()
55
+
56
+ def __enter__(self) -> Client:
57
+ return self
58
+
59
+ def __exit__(self, *exc: object) -> None:
60
+ self.close()
61
+
62
+ def get(self, path: str) -> Any:
63
+ return self._send(self._http.build_request("GET", path))
64
+
65
+ def post(self, path: str, body: dict[str, Any]) -> Any:
66
+ return self._send(self._http.build_request("POST", path, json=body))
67
+
68
+ def _send(self, request: httpx.Request) -> Any:
69
+ self.throttle.before()
70
+ response = self._http.send(request)
71
+ self.throttle.after(response.headers)
72
+ raise_for_response(response)
73
+ return response.json()
74
+
75
+
76
+ def page_body(
77
+ page: int, size: int, sort: dict[str, str] | None, filters: dict[str, Any]
78
+ ) -> dict[str, Any]:
79
+ """Build ANVISA's `PaginationBuilder` body. Pages are 1-based on the wire."""
80
+ if page < 1:
81
+ raise InvalidPageError(f"page must be >= 1 (got {page}); ANVISA pages are 1-based")
82
+ return {"page": page, "size": size, "sorting": dict(sort or {}), "filter": dict(filters)}
83
+
84
+
85
+ T = TypeVar("T")
86
+
87
+
88
+ class PageLike(Protocol[T]):
89
+ content: list[T] | None
90
+ number: int | None
91
+ last: bool | None
92
+
93
+
94
+ def iterate_pages(fetch: Callable[[int], PageLike[T]]) -> Iterator[T]:
95
+ """Yield items across pages. `fetch` takes a 1-based page; responses report 0-based `number`."""
96
+ page_number = 1
97
+ while True:
98
+ page = fetch(page_number)
99
+ yield from page.content or []
100
+ if page.last or not page.content:
101
+ return
102
+ page_number = (page.number or 0) + 2
103
+
104
+
105
+ class Fila:
106
+ """Fila de análise: which processes are queued, in which order, per subqueue."""
107
+
108
+ def __init__(self, client: Client) -> None:
109
+ self._client = client
110
+
111
+ def areas(self) -> list[models.TipoProduto]:
112
+ """Áreas de interesse (Medicamento=1, Dispositivos Médicos=8, ...)."""
113
+ return [
114
+ models.TipoProduto.model_validate(x) for x in self._client.get("/api/v1/fila/areafila")
115
+ ]
116
+
117
+ def grupos(self, area_id: int) -> list[models.ChaveValorLong]:
118
+ """Grupos de fila of an area (Registros, Alterações, Revalidações, ...)."""
119
+ data = self._client.get(f"/api/v1/fila/{area_id}/fila")
120
+ return [models.ChaveValorLong.model_validate(x) for x in data]
121
+
122
+ def subfilas(self, grupo_id: int) -> list[models.ChaveValorInteger]:
123
+ """Subfilas of a grupo; their ids are what `consulta` takes."""
124
+ data = self._client.get(f"/api/v1/fila/{grupo_id}/subfila")
125
+ return [models.ChaveValorInteger.model_validate(x) for x in data]
126
+
127
+ def consulta(self, subfila_id: int) -> list[models.FilaCalculadaDTO]:
128
+ """The whole calculated queue of a subfila, in order. Not paginated by the API."""
129
+ data = self._client.post("/api/v1/fila/consulta", {"filter": {"subfila": subfila_id}})
130
+ return [models.FilaCalculadaDTO.model_validate(x) for x in data]
131
+
132
+
133
+ class Udi:
134
+ """UDI (Unique Device Identification) of medical devices, plus GMDN terms."""
135
+
136
+ def __init__(self, client: Client) -> None:
137
+ self._client = client
138
+
139
+ def search(
140
+ self,
141
+ page: int = 1,
142
+ size: int = 20,
143
+ sort: dict[str, str] | None = None,
144
+ **filters: Any,
145
+ ) -> models.PageUdiDTO:
146
+ """One page of devices. At least one filter is required (`nomeComercial` is verified;
147
+ `udiDi`, `cnpjDetentora`, `codigoGmdn`, `nuRegistro`, ... come from ANVISA's example)."""
148
+ if not filters:
149
+ raise MissingFilterError(
150
+ "udi.search needs at least one filter, e.g. nomeComercial='cateter'"
151
+ )
152
+ data = self._client.post("/api/v1/udi", page_body(page, size, sort, filters))
153
+ return models.PageUdiDTO.model_validate(data)
154
+
155
+ def iter_search(
156
+ self, size: int = 100, sort: dict[str, str] | None = None, **filters: Any
157
+ ) -> Iterator[models.UdiDTO]:
158
+ """Every device matching the filters, across pages."""
159
+ return iterate_pages(lambda page: self.search(page, size, sort, **filters))
160
+
161
+ def get(self, id: int) -> models.DetalheDispositivoDTO:
162
+ return models.DetalheDispositivoDTO.model_validate(self._client.get(f"/api/v1/udi/{id}"))
163
+
164
+ def get_historico(self, id_dispositivo: int, id_historico: int) -> models.DetalheDispositivoDTO:
165
+ data = self._client.get(f"/api/v1/udi/{id_dispositivo}/{id_historico}")
166
+ return models.DetalheDispositivoDTO.model_validate(data)
167
+
168
+ def historicos(self) -> list[models.HistoricoUdiDTO]:
169
+ data = self._client.get("/api/v1/udi/historico")
170
+ return [models.HistoricoUdiDTO.model_validate(x) for x in data]
171
+
172
+ def termos_gmdn(
173
+ self,
174
+ page: int = 1,
175
+ size: int = 20,
176
+ sort: dict[str, str] | None = None,
177
+ **filters: Any,
178
+ ) -> models.PageTermoGMDNDTO:
179
+ data = self._client.post("/api/v1/udi/termoGmdn", page_body(page, size, sort, filters))
180
+ return models.PageTermoGMDNDTO.model_validate(data)
181
+
182
+ def termo_gmdn(self, codigo: str) -> models.TermoGMDNDTO:
183
+ return models.TermoGMDNDTO.model_validate(
184
+ self._client.get(f"/api/v1/udi/termoGmdn/{codigo}")
185
+ )
186
+
187
+
188
+ class Assunto:
189
+ """Assuntos de peticionamento: subject codes, and per code the required documents,
190
+ forms, legal basis and fees. `lista` and `detalhe` are verified live; the catalogs and
191
+ `busca` come from the spec only."""
192
+
193
+ def __init__(self, client: Client) -> None:
194
+ self._client = client
195
+
196
+ def lista(self) -> list[models.AssuntoDTO]:
197
+ """Every subject code with its description (~2,600 entries, one request)."""
198
+ data = self._client.get("/api/v1/assunto/assuntos")
199
+ return [models.AssuntoDTO.model_validate(x) for x in data]
200
+
201
+ def detalhe(self, codigo: int | str) -> models.DetalheAssunto:
202
+ """Full detail of one subject: system, services, forms, checklist, fees by company size."""
203
+ return models.DetalheAssunto.model_validate(self._client.get(f"/api/v1/assunto/{codigo}"))
204
+
205
+ def tipos_solicitacao(self) -> list[models.TipoSolicitacaoDTO]:
206
+ data = self._client.get("/api/v1/assunto/tiposSolicitacao")
207
+ return [models.TipoSolicitacaoDTO.model_validate(x) for x in data]
208
+
209
+ def tipos_produto(self) -> list[models.TipoProdutoDTO]:
210
+ data = self._client.get("/api/v1/assunto/tiposProduto")
211
+ return [models.TipoProdutoDTO.model_validate(x) for x in data]
212
+
213
+ def sistemas(self) -> list[models.SistemaDTO]:
214
+ data = self._client.get("/api/v1/assunto/sistemas")
215
+ return [models.SistemaDTO.model_validate(x) for x in data]
216
+
217
+ def servicos(self) -> list[models.ServicoDTO]:
218
+ data = self._client.get("/api/v1/assunto/servicos")
219
+ return [models.ServicoDTO.model_validate(x) for x in data]
220
+
221
+ def busca(
222
+ self,
223
+ page: int = 1,
224
+ size: int = 20,
225
+ sort: dict[str, str] | None = None,
226
+ **filters: Any,
227
+ ) -> models.PageConsultaAssunto:
228
+ """Paginated search. Filter keys from ANVISA's example, unverified: `codigosAssunto`,
229
+ `servicos`, `sistemas`, `tiposProduto`, `tiposSolicitacao`."""
230
+ data = self._client.post("/api/v1/assunto/", page_body(page, size, sort, filters))
231
+ return models.PageConsultaAssunto.model_validate(data)