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.
- anvisa-0.1.0/.gitignore +13 -0
- anvisa-0.1.0/LICENSE +21 -0
- anvisa-0.1.0/PKG-INFO +58 -0
- anvisa-0.1.0/README.md +36 -0
- anvisa-0.1.0/pyproject.toml +65 -0
- anvisa-0.1.0/src/anvisa/__init__.py +38 -0
- anvisa-0.1.0/src/anvisa/auth.py +119 -0
- anvisa-0.1.0/src/anvisa/cli.py +252 -0
- anvisa-0.1.0/src/anvisa/client.py +231 -0
- anvisa-0.1.0/src/anvisa/errors.py +139 -0
- anvisa-0.1.0/src/anvisa/models.py +646 -0
- anvisa-0.1.0/src/anvisa/throttle.py +60 -0
anvisa-0.1.0/.gitignore
ADDED
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}")
|