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 +38 -0
- anvisa/auth.py +119 -0
- anvisa/cli.py +252 -0
- anvisa/client.py +231 -0
- anvisa/errors.py +139 -0
- anvisa/models.py +646 -0
- anvisa/throttle.py +60 -0
- anvisa-0.1.0.dist-info/METADATA +58 -0
- anvisa-0.1.0.dist-info/RECORD +12 -0
- anvisa-0.1.0.dist-info/WHEEL +4 -0
- anvisa-0.1.0.dist-info/entry_points.txt +2 -0
- anvisa-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|