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.
@@ -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
+ ]
@@ -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}"