anvisa 0.1.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ # python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+
11
+ # never commit credentials
12
+ credentials.env
13
+ .env
anvisa-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vitor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
anvisa-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: anvisa
3
+ Version: 0.1.0
4
+ Summary: Client and CLI for ANVISA's official Consultas Externas API (fila de análise, UDI, assuntos)
5
+ Project-URL: Repository, https://github.com/vasfvitor/anvisa-api
6
+ Author: Vitor
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: anvisa,brazil,health,regulatory,udi
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx<1,>=0.27
19
+ Requires-Dist: pydantic<3,>=2.7
20
+ Requires-Dist: typer>=0.12
21
+ Description-Content-Type: text/markdown
22
+
23
+ # anvisa
24
+
25
+ Python client and CLI for ANVISA's official **Consultas Externas** API
26
+ (fila de análise, UDI de dispositivos médicos, termos GMDN, assuntos de peticionamento).
27
+
28
+ ```bash
29
+ uv tool install anvisa # or: pipx install anvisa
30
+ ```
31
+
32
+ Credentials come from https://api.anvisa.gov.br/ (login Gov.br → Client ID / Client Secret).
33
+ Write them to `~/.config/anvisa/credentials.env` (`CLIENT_ID=...`, `CLIENT_SECRET=...`, chmod 600)
34
+ or export `ANVISA_CLIENT_ID` / `ANVISA_CLIENT_SECRET`.
35
+
36
+ ```bash
37
+ anvisa fila areas
38
+ anvisa fila consulta 167
39
+ anvisa udi search --nome cateter
40
+ anvisa --format json udi get 377
41
+ anvisa assunto lista --busca bioequival
42
+ ```
43
+
44
+ ```python
45
+ from anvisa import Client
46
+
47
+ with Client.from_env() as anvisa:
48
+ queue = anvisa.fila.consulta(167)
49
+ page = anvisa.udi.search(nomeComercial="cateter", size=50)
50
+ ```
51
+
52
+ What the library handles for you, because ANVISA's spec doesn't say so: a User-Agent that
53
+ Cloudflare accepts, token caching and renewal (29-minute tokens, no refresh token), a mirror
54
+ of the gateway's rate limit (burst 25, 1 request/s) so loops never hit 429, 1-based request
55
+ pages against 0-based responses, required filter keys, and typed exceptions for the
56
+ validation errors ANVISA reports as HTTP 500.
57
+
58
+ Full write-up, spec overlay and recorded fixtures: https://github.com/vasfvitor/anvisa-api
anvisa-0.1.0/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # anvisa
2
+
3
+ Python client and CLI for ANVISA's official **Consultas Externas** API
4
+ (fila de análise, UDI de dispositivos médicos, termos GMDN, assuntos de peticionamento).
5
+
6
+ ```bash
7
+ uv tool install anvisa # or: pipx install anvisa
8
+ ```
9
+
10
+ Credentials come from https://api.anvisa.gov.br/ (login Gov.br → Client ID / Client Secret).
11
+ Write them to `~/.config/anvisa/credentials.env` (`CLIENT_ID=...`, `CLIENT_SECRET=...`, chmod 600)
12
+ or export `ANVISA_CLIENT_ID` / `ANVISA_CLIENT_SECRET`.
13
+
14
+ ```bash
15
+ anvisa fila areas
16
+ anvisa fila consulta 167
17
+ anvisa udi search --nome cateter
18
+ anvisa --format json udi get 377
19
+ anvisa assunto lista --busca bioequival
20
+ ```
21
+
22
+ ```python
23
+ from anvisa import Client
24
+
25
+ with Client.from_env() as anvisa:
26
+ queue = anvisa.fila.consulta(167)
27
+ page = anvisa.udi.search(nomeComercial="cateter", size=50)
28
+ ```
29
+
30
+ What the library handles for you, because ANVISA's spec doesn't say so: a User-Agent that
31
+ Cloudflare accepts, token caching and renewal (29-minute tokens, no refresh token), a mirror
32
+ of the gateway's rate limit (burst 25, 1 request/s) so loops never hit 429, 1-based request
33
+ pages against 0-based responses, required filter keys, and typed exceptions for the
34
+ validation errors ANVISA reports as HTTP 500.
35
+
36
+ Full write-up, spec overlay and recorded fixtures: https://github.com/vasfvitor/anvisa-api
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "anvisa"
7
+ version = "0.1.0"
8
+ description = "Client and CLI for ANVISA's official Consultas Externas API (fila de análise, UDI, assuntos)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Vitor" }]
13
+ keywords = ["anvisa", "udi", "regulatory", "brazil", "health"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Healthcare Industry",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27,<1",
25
+ "pydantic>=2.7,<3",
26
+ "typer>=0.12",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/vasfvitor/anvisa-api"
31
+
32
+ [project.scripts]
33
+ anvisa = "anvisa.cli:app"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=8",
38
+ "datamodel-code-generator>=0.30",
39
+ "jsonpath-ng>=1.6",
40
+ "pyyaml>=6",
41
+ "ruff>=0.6",
42
+ ]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/anvisa"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ # tests and scripts need ../../fixtures and ../../spec from the repo, so they are not shipped
49
+ only-include = ["src/anvisa", "README.md", "LICENSE", "pyproject.toml"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = "-m 'not live'"
54
+ markers = ["live: hits the real ANVISA API (needs credentials; run with -m live)"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py310"
59
+ extend-exclude = ["src/anvisa/models.py"]
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "UP", "B", "SIM"]
63
+
64
+ [tool.ruff.lint.per-file-ignores]
65
+ "src/anvisa/cli.py" = ["B008"] # typer.Option() in defaults is the typer idiom
@@ -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
+ ]
@@ -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))
@@ -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}")