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.
@@ -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