zefix-parser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zefix_parser/__init__.py +103 -0
- zefix_parser/_labels.py +37 -0
- zefix_parser/client.py +368 -0
- zefix_parser/identifiers.py +98 -0
- zefix_parser/legal_forms.py +160 -0
- zefix_parser/lindas.py +445 -0
- zefix_parser/py.typed +0 -0
- zefix_parser/rest.py +188 -0
- zefix_parser/schemas.py +159 -0
- zefix_parser-0.1.0.dist-info/METADATA +272 -0
- zefix_parser-0.1.0.dist-info/RECORD +12 -0
- zefix_parser-0.1.0.dist-info/WHEEL +4 -0
zefix_parser/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Typed Python client for Zefix, the Swiss Central Business Name Index."""
|
|
2
|
+
|
|
3
|
+
from .schemas import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
Company,
|
|
6
|
+
CompanyStatus,
|
|
7
|
+
Language,
|
|
8
|
+
OldName,
|
|
9
|
+
ParserError,
|
|
10
|
+
RawPage,
|
|
11
|
+
RegistryEntity,
|
|
12
|
+
RelatedEntity,
|
|
13
|
+
RelationType,
|
|
14
|
+
RetryableError,
|
|
15
|
+
SogcPublication,
|
|
16
|
+
TransportError,
|
|
17
|
+
ZefixError,
|
|
18
|
+
)
|
|
19
|
+
from .identifiers import (
|
|
20
|
+
clean_uid,
|
|
21
|
+
format_chid,
|
|
22
|
+
format_uid,
|
|
23
|
+
is_valid_uid,
|
|
24
|
+
normalize_chid,
|
|
25
|
+
normalize_uid,
|
|
26
|
+
uid_check_digit_ok,
|
|
27
|
+
)
|
|
28
|
+
from .legal_forms import (
|
|
29
|
+
LEGAL_FORM_ABBREVIATIONS,
|
|
30
|
+
LEGAL_FORM_LABELS,
|
|
31
|
+
LegalForm,
|
|
32
|
+
legal_form_abbreviation,
|
|
33
|
+
legal_form_name,
|
|
34
|
+
)
|
|
35
|
+
from .lindas import (
|
|
36
|
+
QUERY_VERSION,
|
|
37
|
+
build_combined_page_query,
|
|
38
|
+
build_count_query,
|
|
39
|
+
build_detail_query,
|
|
40
|
+
build_detail_query_by_uids,
|
|
41
|
+
build_uri_page_query,
|
|
42
|
+
compute_fingerprint,
|
|
43
|
+
parse_count,
|
|
44
|
+
parse_entity_page,
|
|
45
|
+
parse_sparql_json,
|
|
46
|
+
parse_uri_page,
|
|
47
|
+
)
|
|
48
|
+
from .rest import (
|
|
49
|
+
PARSER_VERSION,
|
|
50
|
+
meaningful_old_names,
|
|
51
|
+
normalize_name,
|
|
52
|
+
parse_companies,
|
|
53
|
+
parse_company,
|
|
54
|
+
parse_sogc,
|
|
55
|
+
parse_sogc_list,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AuthenticationError",
|
|
60
|
+
"Company",
|
|
61
|
+
"CompanyStatus",
|
|
62
|
+
"LEGAL_FORM_ABBREVIATIONS",
|
|
63
|
+
"LEGAL_FORM_LABELS",
|
|
64
|
+
"Language",
|
|
65
|
+
"LegalForm",
|
|
66
|
+
"OldName",
|
|
67
|
+
"PARSER_VERSION",
|
|
68
|
+
"QUERY_VERSION",
|
|
69
|
+
"ParserError",
|
|
70
|
+
"RawPage",
|
|
71
|
+
"RegistryEntity",
|
|
72
|
+
"RelatedEntity",
|
|
73
|
+
"RelationType",
|
|
74
|
+
"RetryableError",
|
|
75
|
+
"SogcPublication",
|
|
76
|
+
"TransportError",
|
|
77
|
+
"ZefixError",
|
|
78
|
+
"build_combined_page_query",
|
|
79
|
+
"build_count_query",
|
|
80
|
+
"build_detail_query",
|
|
81
|
+
"build_detail_query_by_uids",
|
|
82
|
+
"build_uri_page_query",
|
|
83
|
+
"clean_uid",
|
|
84
|
+
"compute_fingerprint",
|
|
85
|
+
"format_chid",
|
|
86
|
+
"format_uid",
|
|
87
|
+
"is_valid_uid",
|
|
88
|
+
"legal_form_abbreviation",
|
|
89
|
+
"legal_form_name",
|
|
90
|
+
"meaningful_old_names",
|
|
91
|
+
"normalize_chid",
|
|
92
|
+
"normalize_name",
|
|
93
|
+
"normalize_uid",
|
|
94
|
+
"parse_companies",
|
|
95
|
+
"parse_company",
|
|
96
|
+
"parse_count",
|
|
97
|
+
"parse_entity_page",
|
|
98
|
+
"parse_sogc",
|
|
99
|
+
"parse_sogc_list",
|
|
100
|
+
"parse_sparql_json",
|
|
101
|
+
"parse_uri_page",
|
|
102
|
+
"uid_check_digit_ok",
|
|
103
|
+
]
|
zefix_parser/_labels.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared text normalization for language-tagged register literals.
|
|
2
|
+
|
|
3
|
+
Both LINDAS and the REST API return the same company under several language
|
|
4
|
+
tags. These helpers pick one, always the same one for a given input.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
#: Order in which a language-tagged literal is preferred. The empty string is
|
|
12
|
+
#: an untagged literal, which the register uses for language-neutral values.
|
|
13
|
+
LANGUAGE_PRIORITY = ("de", "fr", "it", "en", "")
|
|
14
|
+
|
|
15
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_whitespace(text: str) -> str:
|
|
19
|
+
"""*text* with runs of whitespace collapsed to single spaces and stripped."""
|
|
20
|
+
return _WHITESPACE.sub(" ", text or "").strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pick_label(labels: dict[str, str]) -> str:
|
|
24
|
+
"""The best label from a ``{language: value}`` mapping, or ``""``."""
|
|
25
|
+
value, _ = pick_label_with_language(labels)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pick_label_with_language(labels: dict[str, str]) -> tuple[str, str]:
|
|
30
|
+
"""The best label and the language tag it came from, or ``("", "")``."""
|
|
31
|
+
for lang in LANGUAGE_PRIORITY:
|
|
32
|
+
if lang in labels:
|
|
33
|
+
return labels[lang], lang
|
|
34
|
+
if labels:
|
|
35
|
+
lang, value = next(iter(labels.items()))
|
|
36
|
+
return value, lang
|
|
37
|
+
return "", ""
|
zefix_parser/client.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""HTTP clients for the two Zefix access paths.
|
|
2
|
+
|
|
3
|
+
Requires the ``http`` extra::
|
|
4
|
+
|
|
5
|
+
pip install zefix-parser[http]
|
|
6
|
+
|
|
7
|
+
:class:`LindasClient` talks to the open SPARQL endpoint and needs no
|
|
8
|
+
credentials. :class:`ZefixRestClient` talks to the PublicREST API, which is
|
|
9
|
+
gated behind HTTP Basic auth; credentials are issued on request by
|
|
10
|
+
``zefix@bj.admin.ch``.
|
|
11
|
+
|
|
12
|
+
Both clients rate-limit themselves and retry transient failures with
|
|
13
|
+
exponential backoff. Give each thread its own client.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from datetime import date, datetime, timezone
|
|
21
|
+
|
|
22
|
+
from .identifiers import format_uid, normalize_chid
|
|
23
|
+
from .lindas import (
|
|
24
|
+
build_combined_page_query,
|
|
25
|
+
build_count_query,
|
|
26
|
+
build_detail_query_by_uids,
|
|
27
|
+
parse_count,
|
|
28
|
+
parse_entity_page,
|
|
29
|
+
parse_uri_page,
|
|
30
|
+
)
|
|
31
|
+
from .rest import parse_companies, parse_company, parse_sogc, parse_sogc_list
|
|
32
|
+
from .schemas import (
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
Company,
|
|
35
|
+
RawPage,
|
|
36
|
+
RegistryEntity,
|
|
37
|
+
RetryableError,
|
|
38
|
+
SogcPublication,
|
|
39
|
+
TransportError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DEFAULT_REST_BASE_URL = "https://www.zefix.admin.ch/ZefixPublicREST/api/v1"
|
|
43
|
+
DEFAULT_LINDAS_ENDPOINT = "https://ld.admin.ch/query"
|
|
44
|
+
MAX_PAGE_SIZE = 2000
|
|
45
|
+
USER_AGENT = "python-zefix-parser/0.1"
|
|
46
|
+
|
|
47
|
+
_MAX_BACKOFF = 30.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _BaseClient:
|
|
51
|
+
"""Throttling, retry and lifecycle shared by both clients."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
timeout: float,
|
|
57
|
+
min_interval: float,
|
|
58
|
+
max_retries: int,
|
|
59
|
+
headers: dict[str, str],
|
|
60
|
+
auth: object | None = None,
|
|
61
|
+
auth_hint: str = "",
|
|
62
|
+
) -> None:
|
|
63
|
+
httpx = _import_httpx()
|
|
64
|
+
if max_retries < 1:
|
|
65
|
+
raise ValueError(f"max_retries must be at least 1, got {max_retries!r}")
|
|
66
|
+
self._min_interval = max(0.0, min_interval)
|
|
67
|
+
self._max_retries = max_retries
|
|
68
|
+
self._last_request_at = 0.0
|
|
69
|
+
self._sleep = time.sleep
|
|
70
|
+
self._auth_hint = auth_hint
|
|
71
|
+
self._client = httpx.Client(timeout=timeout, headers=headers, auth=auth)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Close the underlying connection pool."""
|
|
75
|
+
self._client.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self):
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc_info) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def _throttle(self) -> None:
|
|
84
|
+
if not self._min_interval:
|
|
85
|
+
return
|
|
86
|
+
elapsed = time.monotonic() - self._last_request_at
|
|
87
|
+
if elapsed < self._min_interval:
|
|
88
|
+
self._sleep(self._min_interval - elapsed)
|
|
89
|
+
|
|
90
|
+
def _request(self, method: str, url: str, **kwargs):
|
|
91
|
+
"""Send one request, retrying transient failures. Returns the response."""
|
|
92
|
+
httpx = _import_httpx()
|
|
93
|
+
last_error: Exception | None = None
|
|
94
|
+
for attempt in range(self._max_retries):
|
|
95
|
+
self._throttle()
|
|
96
|
+
self._last_request_at = time.monotonic()
|
|
97
|
+
try:
|
|
98
|
+
response = self._client.request(method, url, **kwargs)
|
|
99
|
+
except httpx.TransportError as exc:
|
|
100
|
+
last_error = RetryableError(f"{method} {url} failed: {exc}")
|
|
101
|
+
self._back_off(attempt)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if response.status_code in (401, 403):
|
|
105
|
+
raise AuthenticationError(
|
|
106
|
+
f"{method} {url} returned {response.status_code}{self._auth_hint}"
|
|
107
|
+
)
|
|
108
|
+
if response.status_code == 429 or response.status_code >= 500:
|
|
109
|
+
last_error = RetryableError(
|
|
110
|
+
f"{method} {url} returned {response.status_code}"
|
|
111
|
+
)
|
|
112
|
+
self._back_off(attempt)
|
|
113
|
+
continue
|
|
114
|
+
return response
|
|
115
|
+
|
|
116
|
+
raise last_error if last_error else RetryableError(f"{method} {url} failed")
|
|
117
|
+
|
|
118
|
+
def _back_off(self, attempt: int) -> None:
|
|
119
|
+
if attempt + 1 < self._max_retries:
|
|
120
|
+
self._sleep(min(2.0**attempt, _MAX_BACKOFF))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ZefixRestClient(_BaseClient):
|
|
124
|
+
"""Client for the Zefix PublicREST API.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
base_url: API root. Defaults to :data:`DEFAULT_REST_BASE_URL`.
|
|
128
|
+
username: HTTP Basic username issued by ``zefix@bj.admin.ch``.
|
|
129
|
+
password: HTTP Basic password.
|
|
130
|
+
timeout: Per-request timeout in seconds.
|
|
131
|
+
min_interval: Minimum seconds between requests.
|
|
132
|
+
max_retries: Attempts per request, including the first.
|
|
133
|
+
user_agent: ``User-Agent`` header to send.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
base_url: str = DEFAULT_REST_BASE_URL,
|
|
139
|
+
*,
|
|
140
|
+
username: str = "",
|
|
141
|
+
password: str = "",
|
|
142
|
+
timeout: float = 30.0,
|
|
143
|
+
min_interval: float = 0.5,
|
|
144
|
+
max_retries: int = 3,
|
|
145
|
+
user_agent: str = USER_AGENT,
|
|
146
|
+
) -> None:
|
|
147
|
+
httpx = _import_httpx()
|
|
148
|
+
auth = httpx.BasicAuth(username, password) if username and password else None
|
|
149
|
+
super().__init__(
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
min_interval=min_interval,
|
|
152
|
+
max_retries=max_retries,
|
|
153
|
+
headers={"Accept": "application/json", "User-Agent": user_agent},
|
|
154
|
+
auth=auth,
|
|
155
|
+
auth_hint=(
|
|
156
|
+
"; the Zefix PublicREST API needs credentials, which "
|
|
157
|
+
"zefix@bj.admin.ch issues on request"
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
self._base_url = base_url.rstrip("/")
|
|
161
|
+
|
|
162
|
+
def get_by_uid(self, uid: str) -> Company | None:
|
|
163
|
+
"""The company with this UID, or ``None`` if the register has none.
|
|
164
|
+
|
|
165
|
+
*uid* is punctuated for you, so ``"CHE123456789"`` and
|
|
166
|
+
``"CHE-123.456.789"`` both work; the API only accepts the latter.
|
|
167
|
+
"""
|
|
168
|
+
data = self._get(f"/company/uid/{format_uid(uid)}")
|
|
169
|
+
return parse_company(data) if data else None
|
|
170
|
+
|
|
171
|
+
def get_by_ehraid(self, ehraid: int) -> Company | None:
|
|
172
|
+
"""The company with this EHRAID, or ``None``."""
|
|
173
|
+
data = self._get(f"/company/ehraid/{int(ehraid)}")
|
|
174
|
+
return parse_company(data) if data else None
|
|
175
|
+
|
|
176
|
+
def get_by_chid(self, chid: str) -> Company | None:
|
|
177
|
+
"""The company with this CHID, or ``None``."""
|
|
178
|
+
data = self._get(f"/company/chid/{normalize_chid(chid)}")
|
|
179
|
+
return parse_company(data) if data else None
|
|
180
|
+
|
|
181
|
+
def search(
|
|
182
|
+
self,
|
|
183
|
+
name: str,
|
|
184
|
+
*,
|
|
185
|
+
active_only: bool = True,
|
|
186
|
+
max_entries: int = 30,
|
|
187
|
+
offset: int = 0,
|
|
188
|
+
) -> list[Company]:
|
|
189
|
+
"""Companies whose name starts with *name*.
|
|
190
|
+
|
|
191
|
+
The endpoint matches the start of the name and caps how much it
|
|
192
|
+
returns. There is no cursor, so shorten the prefix when you need more
|
|
193
|
+
results.
|
|
194
|
+
"""
|
|
195
|
+
payload = {
|
|
196
|
+
"name": name,
|
|
197
|
+
"activeOnly": active_only,
|
|
198
|
+
"maxEntries": int(max_entries),
|
|
199
|
+
"offset": int(offset),
|
|
200
|
+
}
|
|
201
|
+
data = self._post("/company/search", payload)
|
|
202
|
+
return parse_companies(data) if data else []
|
|
203
|
+
|
|
204
|
+
def sogc_by_date(self, day: date) -> list[SogcPublication]:
|
|
205
|
+
"""Every SOGC/SHAB publication issued on *day*."""
|
|
206
|
+
data = self._get(f"/sogc/bydate/{day.isoformat()}")
|
|
207
|
+
return parse_sogc_list(data) if data else []
|
|
208
|
+
|
|
209
|
+
def sogc(self, publication_id: int) -> SogcPublication | None:
|
|
210
|
+
"""One SOGC/SHAB publication by its id, or ``None``."""
|
|
211
|
+
data = self._get(f"/sogc/{int(publication_id)}")
|
|
212
|
+
return parse_sogc(data) if data else None
|
|
213
|
+
|
|
214
|
+
def _get(self, path: str) -> dict | list | None:
|
|
215
|
+
response = self._request("GET", f"{self._base_url}{path}")
|
|
216
|
+
if response.status_code == 404:
|
|
217
|
+
return None
|
|
218
|
+
return _json(response, f"GET {path}")
|
|
219
|
+
|
|
220
|
+
def _post(self, path: str, payload: dict) -> dict | list | None:
|
|
221
|
+
response = self._request("POST", f"{self._base_url}{path}", json=payload)
|
|
222
|
+
if response.status_code == 404:
|
|
223
|
+
return None
|
|
224
|
+
return _json(response, f"POST {path}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class LindasClient(_BaseClient):
|
|
228
|
+
"""Client for the Zefix dataset on the LINDAS SPARQL endpoint.
|
|
229
|
+
|
|
230
|
+
No credentials are needed. The dataset carries the whole commercial
|
|
231
|
+
register, so :meth:`iter_entities` is a long walk: roughly 800,000
|
|
232
|
+
companies at the default page size.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
endpoint: SPARQL endpoint. Defaults to :data:`DEFAULT_LINDAS_ENDPOINT`.
|
|
236
|
+
page_size: Entities per request, at most :data:`MAX_PAGE_SIZE`.
|
|
237
|
+
timeout: Per-request timeout in seconds.
|
|
238
|
+
min_interval: Minimum seconds between requests.
|
|
239
|
+
max_retries: Attempts per request, including the first.
|
|
240
|
+
user_agent: ``User-Agent`` header to send.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
endpoint: str = DEFAULT_LINDAS_ENDPOINT,
|
|
246
|
+
*,
|
|
247
|
+
page_size: int = 500,
|
|
248
|
+
timeout: float = 60.0,
|
|
249
|
+
min_interval: float = 0.5,
|
|
250
|
+
max_retries: int = 4,
|
|
251
|
+
user_agent: str = USER_AGENT,
|
|
252
|
+
) -> None:
|
|
253
|
+
if not 1 <= page_size <= MAX_PAGE_SIZE:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"page_size must be between 1 and {MAX_PAGE_SIZE}, got {page_size!r}"
|
|
256
|
+
)
|
|
257
|
+
super().__init__(
|
|
258
|
+
timeout=timeout,
|
|
259
|
+
min_interval=min_interval,
|
|
260
|
+
max_retries=max_retries,
|
|
261
|
+
headers={
|
|
262
|
+
"Accept": "application/sparql-results+json",
|
|
263
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
264
|
+
"User-Agent": user_agent,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
self._endpoint = endpoint
|
|
268
|
+
self._page_size = page_size
|
|
269
|
+
|
|
270
|
+
def count(self) -> int | None:
|
|
271
|
+
"""How many companies the dataset holds, or ``None`` if it will not say."""
|
|
272
|
+
return parse_count(self.query(build_count_query()))
|
|
273
|
+
|
|
274
|
+
def fetch_by_uids(self, uids: list[str]) -> list[RegistryEntity]:
|
|
275
|
+
"""The companies with these UIDs, in one request.
|
|
276
|
+
|
|
277
|
+
Keep batches to a few hundred; the endpoint times out on very large
|
|
278
|
+
``VALUES`` clauses.
|
|
279
|
+
"""
|
|
280
|
+
if not uids:
|
|
281
|
+
return []
|
|
282
|
+
return parse_entity_page(self.query(build_detail_query_by_uids(uids)))
|
|
283
|
+
|
|
284
|
+
def iter_pages(self, *, cursor_after: str | None = None) -> Iterator[RawPage]:
|
|
285
|
+
"""Walk the register one page at a time, yielding raw responses.
|
|
286
|
+
|
|
287
|
+
Each :class:`~zefix_parser.RawPage` carries the cursor it was fetched
|
|
288
|
+
with. Persist that value and pass it back as *cursor_after* to resume
|
|
289
|
+
an interrupted crawl.
|
|
290
|
+
"""
|
|
291
|
+
for page, _entities in self._walk(cursor_after):
|
|
292
|
+
yield page
|
|
293
|
+
|
|
294
|
+
def iter_entities(
|
|
295
|
+
self, *, cursor_after: str | None = None
|
|
296
|
+
) -> Iterator[RegistryEntity]:
|
|
297
|
+
"""Walk the whole register, yielding one company at a time."""
|
|
298
|
+
for _page, entities in self._walk(cursor_after):
|
|
299
|
+
yield from entities
|
|
300
|
+
|
|
301
|
+
def _walk(
|
|
302
|
+
self, cursor_after: str | None
|
|
303
|
+
) -> Iterator[tuple[RawPage, list[RegistryEntity]]]:
|
|
304
|
+
page_number = 0
|
|
305
|
+
while True:
|
|
306
|
+
query = build_combined_page_query(
|
|
307
|
+
cursor_after=cursor_after, limit=self._page_size
|
|
308
|
+
)
|
|
309
|
+
content = self.query(query)
|
|
310
|
+
entities = parse_entity_page(content)
|
|
311
|
+
page = RawPage(
|
|
312
|
+
page_number=page_number,
|
|
313
|
+
cursor_after=cursor_after or "",
|
|
314
|
+
content=content,
|
|
315
|
+
content_type="application/sparql-results+json",
|
|
316
|
+
fetched_at=datetime.now(timezone.utc),
|
|
317
|
+
row_count=len(entities),
|
|
318
|
+
)
|
|
319
|
+
yield page, entities
|
|
320
|
+
|
|
321
|
+
uris = sorted(_page_uris(content))
|
|
322
|
+
if not uris:
|
|
323
|
+
return
|
|
324
|
+
cursor_after = uris[-1]
|
|
325
|
+
page_number += 1
|
|
326
|
+
|
|
327
|
+
def query(self, sparql: str) -> bytes:
|
|
328
|
+
"""Send *sparql* to the endpoint and return the raw response body.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
TransportError: on a malformed query, which no retry will fix.
|
|
332
|
+
"""
|
|
333
|
+
response = self._request("POST", self._endpoint, data={"query": sparql})
|
|
334
|
+
if response.status_code >= 400:
|
|
335
|
+
raise TransportError(
|
|
336
|
+
f"SPARQL query returned {response.status_code}: {response.text[:200]}"
|
|
337
|
+
)
|
|
338
|
+
return response.content
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _page_uris(content: bytes) -> set[str]:
|
|
342
|
+
"""Every entity URI in a page, including entities the parser dropped.
|
|
343
|
+
|
|
344
|
+
The cursor advances past dropped entities too, so a page whose last rows
|
|
345
|
+
are all invalid still moves the crawl forward.
|
|
346
|
+
"""
|
|
347
|
+
return set(parse_uri_page(content))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _import_httpx():
|
|
351
|
+
try:
|
|
352
|
+
import httpx
|
|
353
|
+
except ImportError as exc: # pragma: no cover - depends on the install
|
|
354
|
+
raise ImportError(
|
|
355
|
+
"the Zefix HTTP clients need httpx: pip install zefix-parser[http]"
|
|
356
|
+
) from exc
|
|
357
|
+
return httpx
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _json(response, what: str) -> dict | list | None:
|
|
361
|
+
if response.status_code >= 400:
|
|
362
|
+
raise TransportError(f"{what} returned {response.status_code}")
|
|
363
|
+
if not response.content:
|
|
364
|
+
return None
|
|
365
|
+
try:
|
|
366
|
+
return response.json()
|
|
367
|
+
except ValueError as exc:
|
|
368
|
+
raise TransportError(f"{what} returned invalid JSON: {exc}") from exc
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Swiss company identifiers: the UID, the CHID and the EHRAID.
|
|
2
|
+
|
|
3
|
+
Every Swiss company carries a UID (Unternehmens-Identifikationsnummer,
|
|
4
|
+
``CHE-123.456.789``), and Zefix stores it three different ways depending on
|
|
5
|
+
where you read it: LINDAS returns it unpunctuated, the PublicREST API requires
|
|
6
|
+
it punctuated, and free-text sources hide it inside a longer string. These
|
|
7
|
+
helpers convert between the forms and validate the check digit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_NON_ALPHANUMERIC = re.compile(r"[^A-Za-z0-9]")
|
|
15
|
+
_UID = re.compile(r"^CHE(\d{3})(\d{3})(\d{3})$")
|
|
16
|
+
_UID_IN_TEXT = re.compile(r"CHE\d{9}")
|
|
17
|
+
_CHID = re.compile(r"^CH(\d{3})(\d{1})(\d{3})(\d{3})(\d{1})$")
|
|
18
|
+
|
|
19
|
+
#: Weights of the UID check digit, ISO 7064 mod 11-10 as the Federal
|
|
20
|
+
#: Statistical Office specifies it.
|
|
21
|
+
UID_WEIGHTS = (5, 4, 3, 2, 7, 6, 5, 4)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_uid(uid: str) -> str:
|
|
25
|
+
"""*uid* with punctuation removed and uppercased: ``"CHE123456789"``.
|
|
26
|
+
|
|
27
|
+
Use this as an index key, and :func:`is_valid_uid` to decide whether the
|
|
28
|
+
value is a real UID.
|
|
29
|
+
"""
|
|
30
|
+
return _NON_ALPHANUMERIC.sub("", uid or "").upper()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_uid(uid: str) -> str:
|
|
34
|
+
"""*uid* in the punctuated form ``"CHE-123.456.789"``.
|
|
35
|
+
|
|
36
|
+
This is the form the PublicREST API expects in its path; the unpunctuated
|
|
37
|
+
form returns 404. Anything other than nine digits behind a ``CHE`` prefix
|
|
38
|
+
comes back normalized and otherwise untouched.
|
|
39
|
+
"""
|
|
40
|
+
normalized = normalize_uid(uid)
|
|
41
|
+
match = _UID.match(normalized)
|
|
42
|
+
if not match:
|
|
43
|
+
return normalized
|
|
44
|
+
return f"CHE-{match.group(1)}.{match.group(2)}.{match.group(3)}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_uid(value: str) -> str:
|
|
48
|
+
"""The first UID embedded anywhere in *value*, or ``""``.
|
|
49
|
+
|
|
50
|
+
For free-text fields that wrap the UID in other content, such as
|
|
51
|
+
``"VAT: CHE-109.807.630 MWST"``.
|
|
52
|
+
"""
|
|
53
|
+
match = _UID_IN_TEXT.search(normalize_uid(value))
|
|
54
|
+
return match.group(0) if match else ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_valid_uid(uid: str) -> bool:
|
|
58
|
+
"""Whether *uid* is a ``CHE`` number with a valid check digit.
|
|
59
|
+
|
|
60
|
+
A mistyped UID can never match the register, so it is worth rejecting
|
|
61
|
+
before you spend a request on it. Placeholders such as ``CHE123456789``
|
|
62
|
+
fail this check.
|
|
63
|
+
"""
|
|
64
|
+
normalized = normalize_uid(uid)
|
|
65
|
+
if not _UID.match(normalized):
|
|
66
|
+
return False
|
|
67
|
+
return uid_check_digit_ok(normalized[3:])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def uid_check_digit_ok(digits: str) -> bool:
|
|
71
|
+
"""Whether nine digits carry a valid UID check digit."""
|
|
72
|
+
if len(digits) != 9 or not digits.isdigit():
|
|
73
|
+
return False
|
|
74
|
+
total = sum(int(digit) * weight for digit, weight in zip(digits, UID_WEIGHTS))
|
|
75
|
+
check = 11 - (total % 11)
|
|
76
|
+
if check == 10:
|
|
77
|
+
return False
|
|
78
|
+
return int(digits[8]) == (0 if check == 11 else check)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_chid(chid: str) -> str:
|
|
82
|
+
"""*chid* with punctuation removed and uppercased: ``"CH12345678901"``."""
|
|
83
|
+
return _NON_ALPHANUMERIC.sub("", chid or "").upper()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_chid(chid: str) -> str:
|
|
87
|
+
"""*chid* in the punctuated form ``"CH-123.4.567.890-1"``.
|
|
88
|
+
|
|
89
|
+
The CHID is the older cantonal register number that Zefix still returns
|
|
90
|
+
alongside the UID. Anything other than eleven digits behind a ``CH``
|
|
91
|
+
prefix comes back normalized and otherwise untouched.
|
|
92
|
+
"""
|
|
93
|
+
normalized = normalize_chid(chid)
|
|
94
|
+
match = _CHID.match(normalized)
|
|
95
|
+
if not match:
|
|
96
|
+
return normalized
|
|
97
|
+
register, office, first, second, check = match.groups()
|
|
98
|
+
return f"CH-{register}.{office}.{first}.{second}-{check}"
|