ares-client 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.
- ares_client-0.1.0/PKG-INFO +170 -0
- ares_client-0.1.0/README.md +145 -0
- ares_client-0.1.0/pyproject.toml +77 -0
- ares_client-0.1.0/pyproject.toml.orig +77 -0
- ares_client-0.1.0/src/ares_client/__init__.py +52 -0
- ares_client-0.1.0/src/ares_client/_transport.py +89 -0
- ares_client-0.1.0/src/ares_client/client.py +181 -0
- ares_client-0.1.0/src/ares_client/exceptions.py +60 -0
- ares_client-0.1.0/src/ares_client/filters.py +62 -0
- ares_client-0.1.0/src/ares_client/ico.py +38 -0
- ares_client-0.1.0/src/ares_client/models.py +174 -0
- ares_client-0.1.0/src/ares_client/py.typed +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ares-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed async client for ARES (Administrativní registr ekonomických subjektů), the Czech business registry API at ares.gov.cz
|
|
5
|
+
Keywords: ares,ares.gov.cz,ico,ičo,dic,dič,rejstřík,czech business registry
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Framework :: AsyncIO
|
|
9
|
+
Classifier: Framework :: Pydantic :: 2
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: pydantic>=2.7
|
|
18
|
+
Requires-Dist: pyrate-limiter>=3.7
|
|
19
|
+
Requires-Dist: tenacity>=9.0
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Repository, https://github.com/jogobeny/ares-client
|
|
22
|
+
Project-URL: Issues, https://github.com/jogobeny/ares-client/issues
|
|
23
|
+
Project-URL: ARES API, https://ares.gov.cz/swagger-ui/
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# ares-client
|
|
27
|
+
|
|
28
|
+
Typed async client for **ARES** (Administrativní registr ekonomických subjektů), the Czech business registry API at [ares.gov.cz](https://ares.gov.cz/swagger-ui/).
|
|
29
|
+
|
|
30
|
+
- Fully typed: pydantic v2 models ([`models.py`]) and a typed exception tree ([`exceptions.py`]).
|
|
31
|
+
- The client retries failed requests with exponential backoff ([`client.py`]). It also obeys the ARES limit of [500 requests each minute][limits].
|
|
32
|
+
- The client validates an IČO locally ([`ico.py`]). Invalid input does not go to the network.
|
|
33
|
+
|
|
34
|
+
The library is **async only**. In synchronous code, wrap each call in `asyncio.run(...)`.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
The library needs **Python 3.12+**.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv add ares-client # or: pip install ares-client
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from ares_client import AresClient
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
async with AresClient() as ares:
|
|
54
|
+
subject = await ares.get_subject("27082440") # an int is also valid, zeros are added
|
|
55
|
+
|
|
56
|
+
print(subject.obchodni_jmeno) # Alza.cz a.s.
|
|
57
|
+
print(subject.sidlo.textova_adresa) # Jankovcova 1522/53, Holešovice, 17000 Praha 7
|
|
58
|
+
print(subject.dic) # CZ27082440
|
|
59
|
+
print(subject.seznam_registraci.dph) # SourceState.AKTIVNI
|
|
60
|
+
print(subject.datum_zaniku) # None — the subject continues
|
|
61
|
+
print(subject.datum_vzniku) # datetime.date(2003, 8, 26)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Client methods
|
|
68
|
+
|
|
69
|
+
| Method | Description |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `get_subject(ico)` | Get one subject. An unknown IČO causes `SubjectNotFound`. |
|
|
72
|
+
| `search(filter)` | Get the subjects that match a filter (`SubjectList`). |
|
|
73
|
+
| `search_code_lists(filter)` | Get the ARES code lists — the translations of codes to names. |
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# search
|
|
77
|
+
page = await ares.search(SubjectFilter(obchodni_jmeno="Alza", pocet=10))
|
|
78
|
+
print(page.pocet_celkem, [s.obchodni_jmeno for s in page.ekonomicke_subjekty])
|
|
79
|
+
|
|
80
|
+
# ARES never returns more than 1 000 results, so one request can hold them all
|
|
81
|
+
page = await ares.search(SubjectFilter(obchodni_jmeno="Alza", pocet=1000))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You can also make the filter first and send it later:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from ares_client import AddressFilter, SubjectFilter
|
|
88
|
+
|
|
89
|
+
filter = SubjectFilter(
|
|
90
|
+
pravni_forma=["112"], # s.r.o. (limited liability company)
|
|
91
|
+
sidlo=AddressFilter(kod_obce=554782), # Praha (RÚIAN code)
|
|
92
|
+
)
|
|
93
|
+
page = await ares.search(filter)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## The `Subject` model
|
|
97
|
+
|
|
98
|
+
The full model, with the same field names and structure as the ARES schema, is in [`models.py`]. For example, the state of the subject in the source registers:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from ares_client import SourceState
|
|
102
|
+
|
|
103
|
+
if subject.seznam_registraci.rzp is SourceState.AKTIVNI:
|
|
104
|
+
... # the subject has an active trade licence
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Errors
|
|
108
|
+
|
|
109
|
+
The errors that ARES reports are typed ([`exceptions.py`]):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
AresError
|
|
113
|
+
├─ InvalidIco # fails locally, without an API call
|
|
114
|
+
└─ AresAPIError # has kod, sub_kod, popis, status_code
|
|
115
|
+
├─ SubjectNotFound
|
|
116
|
+
├─ InvalidRequest
|
|
117
|
+
│ └─ TooManyResults # has .found — the number of matched subjects
|
|
118
|
+
└─ AresServerError
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**The client does not wrap network errors.** A timeout or a refused connection is not an ARES error. These errors keep their httpx form. Thus the caller sees the true cause and can use the usual httpx logic:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import httpx
|
|
125
|
+
from ares_client import AresClient, AresError
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
subject = await ares.get_subject("27082440")
|
|
129
|
+
except httpx.TimeoutException:
|
|
130
|
+
... # the connection was too slow, try again later
|
|
131
|
+
except AresError:
|
|
132
|
+
... # ARES replied with an error
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The client retries network errors (`httpx.TransportError`) first. Only the last error goes to the caller.
|
|
136
|
+
|
|
137
|
+
## ARES limits that the client knows
|
|
138
|
+
|
|
139
|
+
| Limit | Behavior |
|
|
140
|
+
|---|---|
|
|
141
|
+
| Max **1 000** results for each query | ARES refuses a wider query (it does **not** cut the result). You get `TooManyResults` with the match count |
|
|
142
|
+
| Max **100 IČO** values in a filter | ARES refuses more (`InvalidRequest`) |
|
|
143
|
+
| [**500 requests each minute**][limits] | The client obeys the limit with [`pyrate-limiter`]. When the window is full, the request **waits**. It does not fail. ARES sends no rate-limit headers, so a client cannot react to an error |
|
|
144
|
+
| An IČO has exactly 8 digits | `normalize_ico()` adds the leading zeros. A character that is not a digit is an error |
|
|
145
|
+
| An empty filter | ARES refuses it: `InvalidRequest` with `sub_kod=VSTUP_PRAZDNY` |
|
|
146
|
+
|
|
147
|
+
The client retries a request after `408`, `429`, `5xx`, and network errors (exponential backoff with jitter). It does not retry `4xx` input errors. A retry cannot help there.
|
|
148
|
+
|
|
149
|
+
## Important notes
|
|
150
|
+
|
|
151
|
+
- **`pravni_forma`, `cz_nace`, and `financni_urad` are code-list codes, not names** (`"112"` = s.r.o.). Use `search_code_lists()` to translate the codes to names.
|
|
152
|
+
- **Almost all fields are optional.** The Ministry of Finance has no `datum_vzniku`. A self-employed person frequently has no `dic`.
|
|
153
|
+
- **Read the VAT registration from `seznam_registraci.dph`.** This is the ARES state, not the [VAT payer register][adisreg] of the Financial Administration. For tax decisions (for example the § 109 liability), use that register.
|
|
154
|
+
- ARES contains only Czech registrations. It has no foreign subjects.
|
|
155
|
+
|
|
156
|
+
## Resources
|
|
157
|
+
|
|
158
|
+
- [Swagger UI](https://ares.gov.cz/swagger-ui/) · [OpenAPI spec][openapi]
|
|
159
|
+
- [Technical documentation, MF (PDF)][mf-pdf]
|
|
160
|
+
- [Terms of use](https://data.mf.gov.cz/api/ares.html)
|
|
161
|
+
|
|
162
|
+
[`models.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/models.py
|
|
163
|
+
[`exceptions.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/exceptions.py
|
|
164
|
+
[`client.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/client.py
|
|
165
|
+
[`ico.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/ico.py
|
|
166
|
+
[openapi]: https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/v3/api-docs
|
|
167
|
+
[limits]: https://ares.gov.cz/stranky/podminky-provozu
|
|
168
|
+
[adisreg]: https://adisspr.mfcr.cz/dpr/DphReg
|
|
169
|
+
[`pyrate-limiter`]: https://github.com/vutran1710/PyrateLimiter
|
|
170
|
+
[mf-pdf]: https://mf.gov.cz/assets/attachments/2023-08-01_ARES-Technicka-dokumentace-Katalog-verejnych-sluzeb_v07.pdf
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# ares-client
|
|
2
|
+
|
|
3
|
+
Typed async client for **ARES** (Administrativní registr ekonomických subjektů), the Czech business registry API at [ares.gov.cz](https://ares.gov.cz/swagger-ui/).
|
|
4
|
+
|
|
5
|
+
- Fully typed: pydantic v2 models ([`models.py`]) and a typed exception tree ([`exceptions.py`]).
|
|
6
|
+
- The client retries failed requests with exponential backoff ([`client.py`]). It also obeys the ARES limit of [500 requests each minute][limits].
|
|
7
|
+
- The client validates an IČO locally ([`ico.py`]). Invalid input does not go to the network.
|
|
8
|
+
|
|
9
|
+
The library is **async only**. In synchronous code, wrap each call in `asyncio.run(...)`.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
The library needs **Python 3.12+**.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add ares-client # or: pip install ares-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
|
|
24
|
+
from ares_client import AresClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
async with AresClient() as ares:
|
|
29
|
+
subject = await ares.get_subject("27082440") # an int is also valid, zeros are added
|
|
30
|
+
|
|
31
|
+
print(subject.obchodni_jmeno) # Alza.cz a.s.
|
|
32
|
+
print(subject.sidlo.textova_adresa) # Jankovcova 1522/53, Holešovice, 17000 Praha 7
|
|
33
|
+
print(subject.dic) # CZ27082440
|
|
34
|
+
print(subject.seznam_registraci.dph) # SourceState.AKTIVNI
|
|
35
|
+
print(subject.datum_zaniku) # None — the subject continues
|
|
36
|
+
print(subject.datum_vzniku) # datetime.date(2003, 8, 26)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Client methods
|
|
43
|
+
|
|
44
|
+
| Method | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `get_subject(ico)` | Get one subject. An unknown IČO causes `SubjectNotFound`. |
|
|
47
|
+
| `search(filter)` | Get the subjects that match a filter (`SubjectList`). |
|
|
48
|
+
| `search_code_lists(filter)` | Get the ARES code lists — the translations of codes to names. |
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# search
|
|
52
|
+
page = await ares.search(SubjectFilter(obchodni_jmeno="Alza", pocet=10))
|
|
53
|
+
print(page.pocet_celkem, [s.obchodni_jmeno for s in page.ekonomicke_subjekty])
|
|
54
|
+
|
|
55
|
+
# ARES never returns more than 1 000 results, so one request can hold them all
|
|
56
|
+
page = await ares.search(SubjectFilter(obchodni_jmeno="Alza", pocet=1000))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You can also make the filter first and send it later:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from ares_client import AddressFilter, SubjectFilter
|
|
63
|
+
|
|
64
|
+
filter = SubjectFilter(
|
|
65
|
+
pravni_forma=["112"], # s.r.o. (limited liability company)
|
|
66
|
+
sidlo=AddressFilter(kod_obce=554782), # Praha (RÚIAN code)
|
|
67
|
+
)
|
|
68
|
+
page = await ares.search(filter)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## The `Subject` model
|
|
72
|
+
|
|
73
|
+
The full model, with the same field names and structure as the ARES schema, is in [`models.py`]. For example, the state of the subject in the source registers:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from ares_client import SourceState
|
|
77
|
+
|
|
78
|
+
if subject.seznam_registraci.rzp is SourceState.AKTIVNI:
|
|
79
|
+
... # the subject has an active trade licence
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Errors
|
|
83
|
+
|
|
84
|
+
The errors that ARES reports are typed ([`exceptions.py`]):
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
AresError
|
|
88
|
+
├─ InvalidIco # fails locally, without an API call
|
|
89
|
+
└─ AresAPIError # has kod, sub_kod, popis, status_code
|
|
90
|
+
├─ SubjectNotFound
|
|
91
|
+
├─ InvalidRequest
|
|
92
|
+
│ └─ TooManyResults # has .found — the number of matched subjects
|
|
93
|
+
└─ AresServerError
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**The client does not wrap network errors.** A timeout or a refused connection is not an ARES error. These errors keep their httpx form. Thus the caller sees the true cause and can use the usual httpx logic:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import httpx
|
|
100
|
+
from ares_client import AresClient, AresError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
subject = await ares.get_subject("27082440")
|
|
104
|
+
except httpx.TimeoutException:
|
|
105
|
+
... # the connection was too slow, try again later
|
|
106
|
+
except AresError:
|
|
107
|
+
... # ARES replied with an error
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The client retries network errors (`httpx.TransportError`) first. Only the last error goes to the caller.
|
|
111
|
+
|
|
112
|
+
## ARES limits that the client knows
|
|
113
|
+
|
|
114
|
+
| Limit | Behavior |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Max **1 000** results for each query | ARES refuses a wider query (it does **not** cut the result). You get `TooManyResults` with the match count |
|
|
117
|
+
| Max **100 IČO** values in a filter | ARES refuses more (`InvalidRequest`) |
|
|
118
|
+
| [**500 requests each minute**][limits] | The client obeys the limit with [`pyrate-limiter`]. When the window is full, the request **waits**. It does not fail. ARES sends no rate-limit headers, so a client cannot react to an error |
|
|
119
|
+
| An IČO has exactly 8 digits | `normalize_ico()` adds the leading zeros. A character that is not a digit is an error |
|
|
120
|
+
| An empty filter | ARES refuses it: `InvalidRequest` with `sub_kod=VSTUP_PRAZDNY` |
|
|
121
|
+
|
|
122
|
+
The client retries a request after `408`, `429`, `5xx`, and network errors (exponential backoff with jitter). It does not retry `4xx` input errors. A retry cannot help there.
|
|
123
|
+
|
|
124
|
+
## Important notes
|
|
125
|
+
|
|
126
|
+
- **`pravni_forma`, `cz_nace`, and `financni_urad` are code-list codes, not names** (`"112"` = s.r.o.). Use `search_code_lists()` to translate the codes to names.
|
|
127
|
+
- **Almost all fields are optional.** The Ministry of Finance has no `datum_vzniku`. A self-employed person frequently has no `dic`.
|
|
128
|
+
- **Read the VAT registration from `seznam_registraci.dph`.** This is the ARES state, not the [VAT payer register][adisreg] of the Financial Administration. For tax decisions (for example the § 109 liability), use that register.
|
|
129
|
+
- ARES contains only Czech registrations. It has no foreign subjects.
|
|
130
|
+
|
|
131
|
+
## Resources
|
|
132
|
+
|
|
133
|
+
- [Swagger UI](https://ares.gov.cz/swagger-ui/) · [OpenAPI spec][openapi]
|
|
134
|
+
- [Technical documentation, MF (PDF)][mf-pdf]
|
|
135
|
+
- [Terms of use](https://data.mf.gov.cz/api/ares.html)
|
|
136
|
+
|
|
137
|
+
[`models.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/models.py
|
|
138
|
+
[`exceptions.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/exceptions.py
|
|
139
|
+
[`client.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/client.py
|
|
140
|
+
[`ico.py`]: https://github.com/jogobeny/ares-client/blob/main/src/ares_client/ico.py
|
|
141
|
+
[openapi]: https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/v3/api-docs
|
|
142
|
+
[limits]: https://ares.gov.cz/stranky/podminky-provozu
|
|
143
|
+
[adisreg]: https://adisspr.mfcr.cz/dpr/DphReg
|
|
144
|
+
[`pyrate-limiter`]: https://github.com/vutran1710/PyrateLimiter
|
|
145
|
+
[mf-pdf]: https://mf.gov.cz/assets/attachments/2023-08-01_ARES-Technicka-dokumentace-Katalog-verejnych-sluzeb_v07.pdf
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ares-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed async client for ARES (Administrativní registr ekonomických subjektů), the Czech business registry API at ares.gov.cz"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
keywords = [
|
|
8
|
+
"ares",
|
|
9
|
+
"ares.gov.cz",
|
|
10
|
+
"ico",
|
|
11
|
+
"ičo",
|
|
12
|
+
"dic",
|
|
13
|
+
"dič",
|
|
14
|
+
"rejstřík",
|
|
15
|
+
"czech business registry",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Framework :: AsyncIO",
|
|
20
|
+
"Framework :: Pydantic :: 2",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
requires-python = ">=3.12"
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
"pydantic>=2.7",
|
|
32
|
+
"pyrate-limiter>=3.7",
|
|
33
|
+
"tenacity>=9.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/jogobeny/ares-client"
|
|
38
|
+
Issues = "https://github.com/jogobeny/ares-client/issues"
|
|
39
|
+
"ARES API" = "https://ares.gov.cz/swagger-ui/"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
"pytest-asyncio>=0.23",
|
|
45
|
+
"respx>=0.21",
|
|
46
|
+
"ruff>=0.16",
|
|
47
|
+
"basedpyright>=1.31",
|
|
48
|
+
"pytest-cov>=7.1.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
addopts = "-m 'not live'"
|
|
57
|
+
markers = ["live: queries the production ARES API (requires network)"]
|
|
58
|
+
asyncio_mode = "auto"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
extend-select = ["W505"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.pycodestyle]
|
|
67
|
+
max-doc-length = 100
|
|
68
|
+
|
|
69
|
+
[tool.basedpyright]
|
|
70
|
+
pythonVersion = "3.12"
|
|
71
|
+
extraPaths = ["."]
|
|
72
|
+
|
|
73
|
+
[[tool.basedpyright.executionEnvironments]]
|
|
74
|
+
root = "tests"
|
|
75
|
+
reportUnknownArgumentType = false
|
|
76
|
+
reportUnknownMemberType = false
|
|
77
|
+
reportUnusedFunction = false
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ares-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed async client for ARES (Administrativní registr ekonomických subjektů), the Czech business registry API at ares.gov.cz"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
keywords = [
|
|
8
|
+
"ares",
|
|
9
|
+
"ares.gov.cz",
|
|
10
|
+
"ico",
|
|
11
|
+
"ičo",
|
|
12
|
+
"dic",
|
|
13
|
+
"dič",
|
|
14
|
+
"rejstřík",
|
|
15
|
+
"czech business registry",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Framework :: AsyncIO",
|
|
20
|
+
"Framework :: Pydantic :: 2",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
requires-python = ">=3.12"
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
"pydantic>=2.7",
|
|
32
|
+
"pyrate-limiter>=3.7",
|
|
33
|
+
"tenacity>=9.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/jogobeny/ares-client"
|
|
38
|
+
Issues = "https://github.com/jogobeny/ares-client/issues"
|
|
39
|
+
"ARES API" = "https://ares.gov.cz/swagger-ui/"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
"pytest-asyncio>=0.23",
|
|
45
|
+
"respx>=0.21",
|
|
46
|
+
"ruff>=0.16",
|
|
47
|
+
"basedpyright>=1.31",
|
|
48
|
+
"pytest-cov>=7.1.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
addopts = "-m 'not live'"
|
|
57
|
+
markers = ["live: queries the production ARES API (requires network)"]
|
|
58
|
+
asyncio_mode = "auto"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
extend-select = ["W505"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.pycodestyle]
|
|
67
|
+
max-doc-length = 100
|
|
68
|
+
|
|
69
|
+
[tool.basedpyright]
|
|
70
|
+
pythonVersion = "3.12"
|
|
71
|
+
extraPaths = ["."]
|
|
72
|
+
|
|
73
|
+
[[tool.basedpyright.executionEnvironments]]
|
|
74
|
+
root = "tests"
|
|
75
|
+
reportUnknownArgumentType = false
|
|
76
|
+
reportUnknownMemberType = false
|
|
77
|
+
reportUnusedFunction = false
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from .client import AresClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AresAPIError,
|
|
6
|
+
AresError,
|
|
7
|
+
AresServerError,
|
|
8
|
+
InvalidIco,
|
|
9
|
+
InvalidRequest,
|
|
10
|
+
SubjectNotFound,
|
|
11
|
+
TooManyResults,
|
|
12
|
+
)
|
|
13
|
+
from .filters import AddressFilter, CodeListFilter, SubjectFilter
|
|
14
|
+
from .models import (
|
|
15
|
+
Address,
|
|
16
|
+
CodeList,
|
|
17
|
+
CodeListItem,
|
|
18
|
+
CodeListItemName,
|
|
19
|
+
CodeLists,
|
|
20
|
+
DeliveryAddress,
|
|
21
|
+
Registrations,
|
|
22
|
+
SourceState,
|
|
23
|
+
Subject,
|
|
24
|
+
SubjectList,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = version("ares-client")
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Address",
|
|
31
|
+
"AddressFilter",
|
|
32
|
+
"AresAPIError",
|
|
33
|
+
"AresClient",
|
|
34
|
+
"AresError",
|
|
35
|
+
"AresServerError",
|
|
36
|
+
"CodeList",
|
|
37
|
+
"CodeListFilter",
|
|
38
|
+
"CodeListItem",
|
|
39
|
+
"CodeListItemName",
|
|
40
|
+
"CodeLists",
|
|
41
|
+
"DeliveryAddress",
|
|
42
|
+
"InvalidIco",
|
|
43
|
+
"InvalidRequest",
|
|
44
|
+
"Registrations",
|
|
45
|
+
"SourceState",
|
|
46
|
+
"Subject",
|
|
47
|
+
"SubjectFilter",
|
|
48
|
+
"SubjectList",
|
|
49
|
+
"SubjectNotFound",
|
|
50
|
+
"TooManyResults",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
AresAPIError,
|
|
9
|
+
AresServerError,
|
|
10
|
+
InvalidRequest,
|
|
11
|
+
SubjectNotFound,
|
|
12
|
+
TooManyResults,
|
|
13
|
+
)
|
|
14
|
+
from .models import AresModel
|
|
15
|
+
|
|
16
|
+
_SUBJECTS_PATH: Final = "/ekonomicke-subjekty"
|
|
17
|
+
_CODE_LISTS_PATH: Final = "/ciselniky-nazevniky"
|
|
18
|
+
_RETRY_STATUSES: Final = frozenset({408, 429, 500, 502, 503, 504})
|
|
19
|
+
_FOUND_COUNT = re.compile(
|
|
20
|
+
r"\((\d[\d\s ]*)\)"
|
|
21
|
+
) # "Zadaný dotaz vrací příliš mnoho výsledků (57 825)."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def subject_url(base_url: str, ico: str):
|
|
25
|
+
return f"{base_url.rstrip('/')}{_SUBJECTS_PATH}/{ico}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def search_url(base_url: str):
|
|
29
|
+
return f"{base_url.rstrip('/')}{_SUBJECTS_PATH}/vyhledat"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def code_list_search_url(base_url: str):
|
|
33
|
+
return f"{base_url.rstrip('/')}{_CODE_LISTS_PATH}/vyhledat"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def should_retry_status(status: int):
|
|
37
|
+
return status in _RETRY_STATUSES
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _ErrorBody(AresModel):
|
|
41
|
+
"""The `{kod, popis, subKod}` object ARES returns with every error status."""
|
|
42
|
+
|
|
43
|
+
kod: str | None = None
|
|
44
|
+
popis: str | None = None
|
|
45
|
+
sub_kod: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_from_response(status: int, content: bytes):
|
|
49
|
+
"""Translate an ARES error response into the matching exception."""
|
|
50
|
+
try:
|
|
51
|
+
body = _ErrorBody.model_validate_json(content)
|
|
52
|
+
except ValidationError:
|
|
53
|
+
body = _ErrorBody()
|
|
54
|
+
|
|
55
|
+
popis = body.popis or f"ARES returned HTTP {status}"
|
|
56
|
+
|
|
57
|
+
if status == 404 or body.kod == "NENALEZENO":
|
|
58
|
+
return SubjectNotFound(popis, status_code=status, kod=body.kod, sub_kod=body.sub_kod)
|
|
59
|
+
|
|
60
|
+
if body.sub_kod == "VYSTUP_PRILIS_MNOHO_VYSLEDKU":
|
|
61
|
+
match = _FOUND_COUNT.search(popis)
|
|
62
|
+
return TooManyResults(
|
|
63
|
+
popis,
|
|
64
|
+
status_code=status,
|
|
65
|
+
kod=body.kod,
|
|
66
|
+
sub_kod=body.sub_kod,
|
|
67
|
+
found=int(re.sub(r"\D", "", match.group(1))) if match else None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if status >= 500 or body.kod == "OBECNA_CHYBA":
|
|
71
|
+
return AresServerError(popis, status_code=status, kod=body.kod, sub_kod=body.sub_kod)
|
|
72
|
+
|
|
73
|
+
if status == 400 or body.kod == "CHYBA_VSTUPU":
|
|
74
|
+
return InvalidRequest(popis, status_code=status, kod=body.kod, sub_kod=body.sub_kod)
|
|
75
|
+
|
|
76
|
+
return AresAPIError(popis, status_code=status, kod=body.kod, sub_kod=body.sub_kod)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_response[T: BaseModel](model: type[T], response: httpx.Response):
|
|
80
|
+
"""Parse a successful response into `model`, or raise the matching `AresAPIError`."""
|
|
81
|
+
if response.status_code >= 400:
|
|
82
|
+
raise error_from_response(response.status_code, response.content)
|
|
83
|
+
try:
|
|
84
|
+
return model.model_validate_json(response.content)
|
|
85
|
+
except ValidationError as exc:
|
|
86
|
+
raise AresServerError(
|
|
87
|
+
"ARES returned a response that does not match the expected schema",
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
) from exc
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from pyrate_limiter import Duration, Limiter, Rate
|
|
6
|
+
from tenacity import (
|
|
7
|
+
AsyncRetrying,
|
|
8
|
+
RetryCallState,
|
|
9
|
+
retry_if_exception_type,
|
|
10
|
+
retry_if_result,
|
|
11
|
+
stop_after_attempt,
|
|
12
|
+
wait_exponential_jitter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ._transport import (
|
|
16
|
+
code_list_search_url,
|
|
17
|
+
parse_response,
|
|
18
|
+
search_url,
|
|
19
|
+
should_retry_status,
|
|
20
|
+
subject_url,
|
|
21
|
+
)
|
|
22
|
+
from .filters import CodeListFilter, SubjectFilter
|
|
23
|
+
from .ico import normalize_ico
|
|
24
|
+
from .models import CodeLists, Subject, SubjectList
|
|
25
|
+
|
|
26
|
+
_WAIT = wait_exponential_jitter(initial=0.5, max=8.0)
|
|
27
|
+
_BASE_URL = "https://ares.gov.cz/ekonomicke-subjekty-v-be/rest"
|
|
28
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
29
|
+
_RATE = Rate(500, Duration.MINUTE) # https://ares.gov.cz/stranky/podminky-provozu
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _return_last_value(retry_state: RetryCallState):
|
|
33
|
+
"""Return the final attempt. Do not let tenacity raise `RetryError`.
|
|
34
|
+
|
|
35
|
+
With the response available, `parse_response` makes the correct
|
|
36
|
+
`AresAPIError` from it. A transport failure keeps its httpx form, because
|
|
37
|
+
`outcome.result()` raises the error from the attempt again.
|
|
38
|
+
|
|
39
|
+
https://tenacity.readthedocs.io/en/latest/index.html#custom-callbacks
|
|
40
|
+
"""
|
|
41
|
+
assert retry_state.outcome is not None
|
|
42
|
+
return cast("httpx.Response", retry_state.outcome.result())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_retryable(response: httpx.Response):
|
|
46
|
+
return should_retry_status(response.status_code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_RETRY_CONDITION = retry_if_exception_type(httpx.TransportError) | retry_if_result(_is_retryable)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _retrying(max_retries: int):
|
|
53
|
+
return AsyncRetrying(
|
|
54
|
+
stop=stop_after_attempt(max_retries + 1),
|
|
55
|
+
wait=_WAIT,
|
|
56
|
+
retry=_RETRY_CONDITION,
|
|
57
|
+
retry_error_callback=_return_last_value,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AresClient:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
base_url: str = _BASE_URL,
|
|
66
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
67
|
+
max_retries: int = 3,
|
|
68
|
+
):
|
|
69
|
+
self.base_url: str = base_url
|
|
70
|
+
self.max_retries: int = max_retries
|
|
71
|
+
self._limiter: Limiter = Limiter(_RATE)
|
|
72
|
+
self._client: httpx.AsyncClient = httpx.AsyncClient(timeout=timeout)
|
|
73
|
+
|
|
74
|
+
async def _request[T: BaseModel](
|
|
75
|
+
self, model: type[T], method: str, url: str, *, json: dict[str, object] | None = None
|
|
76
|
+
):
|
|
77
|
+
"""Send the request and return the response parsed into `model`.
|
|
78
|
+
|
|
79
|
+
ARES errors become `AresAPIError`. The client does not wrap network
|
|
80
|
+
failures. After the last retry, they go to the caller as httpx errors
|
|
81
|
+
(`httpx.TimeoutException`, `httpx.ConnectError`, ...). Thus the true
|
|
82
|
+
cause stays visible.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
async def attempt_once():
|
|
86
|
+
_ = await self._limiter.try_acquire_async("ares")
|
|
87
|
+
return await self._client.request(method, url, json=json)
|
|
88
|
+
|
|
89
|
+
return parse_response(model, await _retrying(self.max_retries)(attempt_once))
|
|
90
|
+
|
|
91
|
+
async def get_subject(self, ico: str | int):
|
|
92
|
+
"""Return the subject with the given IČO.
|
|
93
|
+
|
|
94
|
+
The IČO can be an `int` or a `string`. The client adds the leading zeros.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> async with AresClient() as ares:
|
|
98
|
+
... subject = await ares.get_subject(27082440)
|
|
99
|
+
>>> subject.ico
|
|
100
|
+
'27082440'
|
|
101
|
+
>>> subject.obchodni_jmeno
|
|
102
|
+
'Alza.cz a.s.'
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
`InvalidIco`: the IČO failed the local validation. No request was sent.
|
|
106
|
+
`SubjectNotFound`: ARES does not have a subject with this IČO.
|
|
107
|
+
"""
|
|
108
|
+
return await self._request(Subject, "GET", subject_url(self.base_url, normalize_ico(ico)))
|
|
109
|
+
|
|
110
|
+
async def search(self, filter: SubjectFilter):
|
|
111
|
+
"""Return the subjects that match the filter.
|
|
112
|
+
|
|
113
|
+
ARES combines different filter fields with AND. It combines the values
|
|
114
|
+
in one list with OR.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
>>> async with AresClient() as ares:
|
|
118
|
+
... result = await ares.search(SubjectFilter(obchodni_jmeno="Alza"))
|
|
119
|
+
>>> result.pocet_celkem
|
|
120
|
+
2
|
|
121
|
+
>>> [s.obchodni_jmeno for s in result.ekonomicke_subjekty]
|
|
122
|
+
['Alza.cz a.s.', 'MS - alza, s.r.o.']
|
|
123
|
+
|
|
124
|
+
>>> # narrowing down — here to joint-stock companies:
|
|
125
|
+
>>> narrowed = SubjectFilter(obchodni_jmeno="Alza", pravni_forma=["121"])
|
|
126
|
+
>>> async with AresClient() as ares:
|
|
127
|
+
... result = await ares.search(narrowed)
|
|
128
|
+
>>> [s.obchodni_jmeno for s in result.ekonomicke_subjekty]
|
|
129
|
+
['Alza.cz a.s.']
|
|
130
|
+
|
|
131
|
+
`start` and `pocet` divide a result set into pages. The pages connect
|
|
132
|
+
into the full set in the initial order. `pocet_celkem` shows the total
|
|
133
|
+
match count on each page. Thus it also shows the limit for paging.
|
|
134
|
+
|
|
135
|
+
Paging cannot go around the limit of 1 000 results. ARES compares the
|
|
136
|
+
limit with the total match count before it reads `start` and `pocet`.
|
|
137
|
+
Thus ARES refuses each request for an oversized query — `start=1000`
|
|
138
|
+
does not get the remainder, and `start=999, pocet=1` also fails. Only a
|
|
139
|
+
narrower filter helps. `TooManyResults.found` shows the size of such a
|
|
140
|
+
result set. Below the limit, one request is always sufficient —
|
|
141
|
+
`pocet=1000` holds the full result set.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
InvalidRequest: ARES refused the filter, for example an empty one.
|
|
145
|
+
TooManyResults: more than 1 000 subjects match the query.
|
|
146
|
+
"""
|
|
147
|
+
return await self._request(
|
|
148
|
+
SubjectList, "POST", search_url(self.base_url), json=filter.to_payload()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def search_code_lists(self, filter: CodeListFilter):
|
|
152
|
+
"""Return the code lists that match the filter.
|
|
153
|
+
|
|
154
|
+
ARES uses code-list codes in many fields, for example `pravni_forma`.
|
|
155
|
+
This endpoint translates the codes to names. An empty filter is valid
|
|
156
|
+
here. It returns all the code lists.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
>>> async with AresClient() as ares:
|
|
160
|
+
... result = await ares.search_code_lists(
|
|
161
|
+
... CodeListFilter(kod_ciselniku="PravniForma", zdroj_ciselniku="res")
|
|
162
|
+
... )
|
|
163
|
+
>>> names = result.ciselniky[0].polozky_ciselniku
|
|
164
|
+
>>> next(n.nazev[0].nazev for n in names if n.kod == "112")
|
|
165
|
+
'Společnost s ručením omezeným'
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
InvalidRequest: ARES refused the filter.
|
|
169
|
+
"""
|
|
170
|
+
return await self._request(
|
|
171
|
+
CodeLists, "POST", code_list_search_url(self.base_url), json=filter.to_payload()
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def aclose(self):
|
|
175
|
+
await self._client.aclose()
|
|
176
|
+
|
|
177
|
+
async def __aenter__(self):
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
async def __aexit__(self, exc_type, exc, tb): # pyright:ignore[reportMissingParameterType, reportUnknownParameterType]
|
|
181
|
+
await self.aclose()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""https://ares.gov.cz/swagger-ui/#/ekonomicke-subjekty/vratEkonomickySubjekt"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AresError(Exception):
|
|
5
|
+
"""Base class for every error this library raises."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidIco(AresError, ValueError):
|
|
9
|
+
"""The IČO failed the local validation. No request was sent."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, value: object, reason: str):
|
|
12
|
+
self.value: object = value
|
|
13
|
+
self.reason: str = reason
|
|
14
|
+
super().__init__(f"Invalid IČO {value!r}: {reason}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AresAPIError(AresError):
|
|
18
|
+
"""ARES replied with an error status."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self, popis: str, *, status_code: int, kod: str | None = None, sub_kod: str | None = None
|
|
22
|
+
):
|
|
23
|
+
self.popis: str = popis
|
|
24
|
+
self.kod: str | None = kod
|
|
25
|
+
self.sub_kod: str | None = sub_kod
|
|
26
|
+
self.status_code: int = status_code
|
|
27
|
+
super().__init__(popis)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SubjectNotFound(AresAPIError):
|
|
31
|
+
"""ARES does not have a subject with this IČO."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidRequest(AresAPIError):
|
|
35
|
+
"""ARES refused the input."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TooManyResults(InvalidRequest):
|
|
39
|
+
"""More than 1 000 subjects match the query.
|
|
40
|
+
|
|
41
|
+
ARES refuses such a query. It does not cut the result. A retry without a
|
|
42
|
+
change cannot help — make the filter narrower. `found` is the number of
|
|
43
|
+
matched subjects, if the error message contains it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
popis: str,
|
|
49
|
+
*,
|
|
50
|
+
status_code: int,
|
|
51
|
+
kod: str | None = None,
|
|
52
|
+
sub_kod: str | None = None,
|
|
53
|
+
found: int | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.found: int | None = found
|
|
56
|
+
super().__init__(popis, status_code=status_code, kod=kod, sub_kod=sub_kod)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AresServerError(AresAPIError):
|
|
60
|
+
"""ARES failed on its side (HTTP 5xx), and the retries did not help."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import ConfigDict, field_validator
|
|
5
|
+
|
|
6
|
+
from .ico import normalize_ico
|
|
7
|
+
from .models import AresModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _FilterModel(AresModel):
|
|
11
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
def to_payload(self):
|
|
14
|
+
return self.model_dump(by_alias=True, exclude_none=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AddressFilter(_FilterModel):
|
|
18
|
+
"""AARES schema `AdresaFiltr`."""
|
|
19
|
+
|
|
20
|
+
kod_casti_obce: int | None = None
|
|
21
|
+
kod_spravniho_obvodu: int | None = None
|
|
22
|
+
kod_mestske_casti_obvodu: int | None = None
|
|
23
|
+
kod_ulice: int | None = None
|
|
24
|
+
cislo_domovni: int | None = None
|
|
25
|
+
kod_obce: int | None = None
|
|
26
|
+
cislo_orientacni: int | None = None
|
|
27
|
+
cislo_orientacni_pismeno: str | None = None
|
|
28
|
+
textova_adresa: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SubjectFilter(_FilterModel):
|
|
32
|
+
"""ARES schema `EkonomickeSubjektyKomplexFiltr`."""
|
|
33
|
+
|
|
34
|
+
start: int = 0
|
|
35
|
+
pocet: int = 100
|
|
36
|
+
razeni: list[str] | None = None
|
|
37
|
+
ico: list[str] | None = None
|
|
38
|
+
obchodni_jmeno: str | None = None
|
|
39
|
+
sidlo: AddressFilter | None = None
|
|
40
|
+
pravni_forma: list[str] | None = None
|
|
41
|
+
financni_urad: list[str] | None = None
|
|
42
|
+
pravni_forma_ros: list[str] | None = None
|
|
43
|
+
cz_nace: list[str] | None = None
|
|
44
|
+
|
|
45
|
+
@field_validator("ico", mode="before")
|
|
46
|
+
@classmethod
|
|
47
|
+
def _normalize_icos(cls, value: str | int | Iterable[str | int] | None):
|
|
48
|
+
if value is None:
|
|
49
|
+
return None
|
|
50
|
+
if isinstance(value, str | int):
|
|
51
|
+
return [normalize_ico(value)]
|
|
52
|
+
return [normalize_ico(v) for v in value]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CodeListFilter(_FilterModel):
|
|
56
|
+
"""ARES schema `CiselnikyZakladniFiltr`."""
|
|
57
|
+
|
|
58
|
+
start: int = 0
|
|
59
|
+
pocet: int = 100
|
|
60
|
+
razeni: list[str] | None = None
|
|
61
|
+
zdroj_ciselniku: str | None = None
|
|
62
|
+
kod_ciselniku: str | None = None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .exceptions import InvalidIco
|
|
2
|
+
|
|
3
|
+
_WEIGHTS = (8, 7, 6, 5, 4, 3, 2)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ico_checksum_ok(ico: str):
|
|
7
|
+
"""Verify the modulo-11 check digit of an eight-digit IČO.
|
|
8
|
+
|
|
9
|
+
See https://w.wiki/Tqv6
|
|
10
|
+
"""
|
|
11
|
+
if len(ico) != 8 or not ico.isdigit():
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
total = sum(int(d) * w for d, w in zip(ico[:7], _WEIGHTS, strict=True))
|
|
15
|
+
expected = (11 - total % 11) % 10
|
|
16
|
+
return int(ico[7]) == expected
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_ico(value: str | int):
|
|
20
|
+
"""Return the IČO in the canonical eight-digit form that ARES expects.
|
|
21
|
+
|
|
22
|
+
The function adds the leading zeros. The input must contain only digits.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
InvalidIco: the value contains a character that is not a digit, has more
|
|
26
|
+
than eight digits, or has an incorrect check digit.
|
|
27
|
+
"""
|
|
28
|
+
digits = str(value)
|
|
29
|
+
|
|
30
|
+
if not digits.isdigit():
|
|
31
|
+
raise InvalidIco(value, "contains characters other than digits")
|
|
32
|
+
if len(digits) > 8:
|
|
33
|
+
raise InvalidIco(value, f"has {len(digits)} digits, an IČO has at most 8")
|
|
34
|
+
|
|
35
|
+
padded = digits.zfill(8)
|
|
36
|
+
if not ico_checksum_ok(padded):
|
|
37
|
+
raise InvalidIco(value, "check digit does not match")
|
|
38
|
+
return padded
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""https://ares.gov.cz/swagger-ui/#/ekonomicke-subjekty/vratEkonomickySubjekt"""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import ClassVar, override
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AresModel(BaseModel):
|
|
12
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
13
|
+
alias_generator=to_camel,
|
|
14
|
+
populate_by_name=True,
|
|
15
|
+
extra="ignore",
|
|
16
|
+
frozen=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SourceState(StrEnum):
|
|
21
|
+
"""ARES schema `StavZdroje`."""
|
|
22
|
+
|
|
23
|
+
AKTIVNI = "AKTIVNI"
|
|
24
|
+
BUDOUCI = "BUDOUCI"
|
|
25
|
+
HISTORICKY = "HISTORICKY"
|
|
26
|
+
LOGICKY_SMAZANY = "LOGICKY_SMAZANY"
|
|
27
|
+
NEEXISTUJICI = "NEEXISTUJICI"
|
|
28
|
+
POZASTAVENY = "POZASTAVENY"
|
|
29
|
+
ZANIKLY = "ZANIKLY"
|
|
30
|
+
UNKNOWN = "UNKNOWN"
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
@classmethod
|
|
34
|
+
def _missing_(cls, value: object):
|
|
35
|
+
return cls.UNKNOWN
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Address(AresModel):
|
|
39
|
+
"""ARES schema `Adresa`."""
|
|
40
|
+
|
|
41
|
+
kod_statu: str | None = None
|
|
42
|
+
nazev_statu: str | None = None
|
|
43
|
+
kod_kraje: int | None = None
|
|
44
|
+
nazev_kraje: str | None = None
|
|
45
|
+
kod_okresu: int | None = None
|
|
46
|
+
nazev_okresu: str | None = None
|
|
47
|
+
kod_obce: int | None = None
|
|
48
|
+
nazev_obce: str | None = None
|
|
49
|
+
kod_spravniho_obvodu: int | None = None
|
|
50
|
+
nazev_spravniho_obvodu: str | None = None
|
|
51
|
+
kod_mestskeho_obvodu: int | None = None
|
|
52
|
+
nazev_mestskeho_obvodu: str | None = None
|
|
53
|
+
kod_mestske_casti_obvodu: int | None = None
|
|
54
|
+
kod_ulice: int | None = None
|
|
55
|
+
nazev_mestske_casti_obvodu: str | None = None
|
|
56
|
+
nazev_ulice: str | None = None
|
|
57
|
+
cislo_domovni: int | None = None
|
|
58
|
+
doplnek_adresy: str | None = None
|
|
59
|
+
kod_casti_obce: int | None = None
|
|
60
|
+
cislo_orientacni: int | None = None
|
|
61
|
+
cislo_orientacni_pismeno: str | None = None
|
|
62
|
+
nazev_casti_obce: str | None = None
|
|
63
|
+
kod_adresniho_mista: int | None = None
|
|
64
|
+
psc: int | None = None
|
|
65
|
+
textova_adresa: str | None = None
|
|
66
|
+
cislo_do_adresy: str | None = None
|
|
67
|
+
standardizace_adresy: bool | None = None
|
|
68
|
+
psc_txt: str | None = None
|
|
69
|
+
typ_cislo_domovni: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DeliveryAddress(AresModel):
|
|
73
|
+
"""ARES schema `AdresaDorucovaci`."""
|
|
74
|
+
|
|
75
|
+
radek_adresy1: str | None = None
|
|
76
|
+
radek_adresy2: str | None = None
|
|
77
|
+
radek_adresy3: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _source_state_alias(name: str):
|
|
81
|
+
return to_camel(f"stav_zdroje_{name}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Registrations(AresModel):
|
|
85
|
+
"""ARES schema `SeznamRegistraci`."""
|
|
86
|
+
|
|
87
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(alias_generator=_source_state_alias)
|
|
88
|
+
|
|
89
|
+
ros: SourceState | None = None # Registr osob
|
|
90
|
+
vr: SourceState | None = None # Veřejné restříky
|
|
91
|
+
res: SourceState | None = None # Registr ekonomických subjektů
|
|
92
|
+
rzp: SourceState | None = None # Registr živnostenského podnikání
|
|
93
|
+
nrpzs: SourceState | None = None # Národní registr poskytovatelů zdravotních služeb
|
|
94
|
+
rpsh: SourceState | None = None # Registr politických stran a hnutí
|
|
95
|
+
rcns: SourceState | None = None # Registr církví a náboženských společností
|
|
96
|
+
szr: SourceState | None = None # Společný zemědělský registr
|
|
97
|
+
dph: SourceState | None = None # Registr plátců DPH
|
|
98
|
+
sk_dph: SourceState | None = None # Registr plátců skupinového DPH
|
|
99
|
+
sd: SourceState | None = None # Registr plátců spotřební daně
|
|
100
|
+
ir: SourceState | None = None # Insolvenční rejstřík
|
|
101
|
+
ceu: SourceState | None = None # Centrální evidence úpadců
|
|
102
|
+
rs: SourceState | None = None # Registr škol
|
|
103
|
+
red: SourceState | None = None # Registr evidence dotací
|
|
104
|
+
monitor: SourceState | None = None # Monitor účetních jednotek státu
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Subject(AresModel):
|
|
108
|
+
"""ARES schema `EkonomickySubjekt`."""
|
|
109
|
+
|
|
110
|
+
ico: str | None = None
|
|
111
|
+
obchodni_jmeno: str | None = None
|
|
112
|
+
sidlo: Address | None = None
|
|
113
|
+
pravni_forma: str | None = None
|
|
114
|
+
pravni_forma_ros: str | None = None
|
|
115
|
+
financni_urad: str | None = None
|
|
116
|
+
datum_vzniku: date | None = None
|
|
117
|
+
datum_zaniku: date | None = None
|
|
118
|
+
datum_aktualizace: date | None = None
|
|
119
|
+
dic: str | None = None
|
|
120
|
+
ico_id: str | None = None
|
|
121
|
+
adresa_dorucovaci: DeliveryAddress | None = None
|
|
122
|
+
cz_nace2008: list[str] = Field(default_factory=list)
|
|
123
|
+
seznam_registraci: Registrations | None = None
|
|
124
|
+
primarni_zdroj: str | None = None
|
|
125
|
+
dalsi_udaje: list[dict[str, object]] = Field(default_factory=list)
|
|
126
|
+
cz_nace: list[str] = Field(default_factory=list)
|
|
127
|
+
sub_registr_szr: str | None = None
|
|
128
|
+
dic_sk_dph: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SubjectList(AresModel):
|
|
132
|
+
"""ARES schema `EkonomickeSubjektySeznam`."""
|
|
133
|
+
|
|
134
|
+
pocet_celkem: int = 0
|
|
135
|
+
ekonomicke_subjekty: list[Subject] = Field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
def __len__(self):
|
|
138
|
+
return len(self.ekonomicke_subjekty)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CodeListItemName(AresModel):
|
|
142
|
+
"""ARES schema `NazevPolozky`."""
|
|
143
|
+
|
|
144
|
+
kod_jazyka: str | None = None
|
|
145
|
+
nazev: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class CodeListItem(AresModel):
|
|
149
|
+
"""ARES schema `PolozkaCiselniku`."""
|
|
150
|
+
|
|
151
|
+
kod: str | None = None
|
|
152
|
+
nazev: list[CodeListItemName] = Field(default_factory=list)
|
|
153
|
+
platnost_od: date | None = None
|
|
154
|
+
platnost_do: date | None = None
|
|
155
|
+
kod_nadrizeny: str | None = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class CodeList(AresModel):
|
|
159
|
+
"""ARES schema `Ciselnik`."""
|
|
160
|
+
|
|
161
|
+
kod_ciselniku: str | None = None
|
|
162
|
+
nazev_ciselniku: str | None = None
|
|
163
|
+
polozky_ciselniku: list[CodeListItem] = Field(default_factory=list)
|
|
164
|
+
zdroj_ciselniku: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class CodeLists(AresModel):
|
|
168
|
+
"""ARES schema `CiselnikyNazevnikSeznam`."""
|
|
169
|
+
|
|
170
|
+
pocet_celkem: int = 0
|
|
171
|
+
ciselniky: list[CodeList] = Field(default_factory=list)
|
|
172
|
+
|
|
173
|
+
def __len__(self):
|
|
174
|
+
return len(self.ciselniky)
|
|
File without changes
|