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,160 @@
1
+ """The eCH-0097 legal-form codes Zefix and SHAB publish, with their names.
2
+
3
+ Both Swiss registers identify a company's legal form by a four-digit eCH-0097
4
+ code. The name itself differs per language: code ``0106`` is an
5
+ *Aktiengesellschaft*, a *Société anonyme* and a *Società anonima* depending on
6
+ where the company is seated. This module is the lookup table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+
14
+ class LegalForm(str, Enum):
15
+ """eCH-0097 legal-form codes used by Zefix and SHAB."""
16
+
17
+ SOLE_PROPRIETORSHIP = "0101"
18
+ GENERAL_PARTNERSHIP = "0103"
19
+ LIMITED_PARTNERSHIP = "0104"
20
+ PARTNERSHIP_LIMITED_BY_SHARES = "0105"
21
+ CORPORATION = "0106"
22
+ LIMITED_LIABILITY_COMPANY = "0107"
23
+ COOPERATIVE = "0108"
24
+ ASSOCIATION = "0109"
25
+ FOUNDATION = "0110"
26
+ FOREIGN_BRANCH = "0111"
27
+ COLLECTIVE_INVESTMENT_LIMITED_PARTNERSHIP = "0114"
28
+ SICAV = "0115"
29
+ PUBLIC_LAW_INSTITUTE = "0117"
30
+ UNDIVIDED_ESTATE_REPRESENTATIVE = "0119"
31
+ SWISS_BRANCH = "0151"
32
+
33
+
34
+ #: Legal-form names by code, then by language tag.
35
+ LEGAL_FORM_LABELS: dict[str, dict[str, str]] = {
36
+ "0101": {
37
+ "de": "Einzelunternehmen",
38
+ "fr": "Entreprise individuelle",
39
+ "it": "Impresa individuale",
40
+ "en": "Sole proprietorship",
41
+ },
42
+ "0103": {
43
+ "de": "Kollektivgesellschaft",
44
+ "fr": "Société en nom collectif",
45
+ "it": "Società in nome collettivo",
46
+ "en": "General partnership",
47
+ },
48
+ "0104": {
49
+ "de": "Kommanditgesellschaft",
50
+ "fr": "Société en commandite",
51
+ "it": "Società in accomandita",
52
+ "en": "Limited partnership",
53
+ },
54
+ "0105": {
55
+ "de": "Kommanditaktiengesellschaft",
56
+ "fr": "Société en commandite par actions",
57
+ "it": "Società in accomandita per azioni",
58
+ "en": "Partnership limited by shares",
59
+ },
60
+ "0106": {
61
+ "de": "Aktiengesellschaft",
62
+ "fr": "Société anonyme",
63
+ "it": "Società anonima",
64
+ "en": "Corporation",
65
+ },
66
+ "0107": {
67
+ "de": "Gesellschaft mit beschränkter Haftung",
68
+ "fr": "Société à responsabilité limitée",
69
+ "it": "Società a garanzia limitata",
70
+ "en": "Limited liability company",
71
+ },
72
+ "0108": {
73
+ "de": "Genossenschaft",
74
+ "fr": "Société coopérative",
75
+ "it": "Società cooperativa",
76
+ "en": "Cooperative",
77
+ },
78
+ "0109": {
79
+ "de": "Verein",
80
+ "fr": "Association",
81
+ "it": "Associazione",
82
+ "en": "Association",
83
+ },
84
+ "0110": {
85
+ "de": "Stiftung",
86
+ "fr": "Fondation",
87
+ "it": "Fondazione",
88
+ "en": "Foundation",
89
+ },
90
+ "0111": {
91
+ "de": "Zweigniederlassung ausländischer Gesellschaft",
92
+ "fr": "Succursale étrangère inscrite au registre du commerce",
93
+ "it": "Succursale estera iscritta nel registro di commercio",
94
+ "en": "Branch of a foreign company",
95
+ },
96
+ "0114": {
97
+ "de": "Kommanditgesellschaft für kollektive Kapitalanlagen",
98
+ "fr": "Société en commandite de placements collectifs",
99
+ "it": "Società in accomandita per investimenti collettivi di capitale",
100
+ "en": "Limited partnership for collective investment",
101
+ },
102
+ "0115": {
103
+ "de": "Investmentgesellschaft mit variablem Kapital (SICAV)",
104
+ "fr": "Société d'investissement à capital variable (SICAV)",
105
+ "it": "Società di investimento a capitale variabile (SICAV)",
106
+ "en": "Investment company with variable capital (SICAV)",
107
+ },
108
+ "0117": {
109
+ "de": "Institut des öffentlichen Rechts",
110
+ "fr": "Institut de droit public",
111
+ "it": "Istituto di diritto pubblico",
112
+ "en": "Public-law institute",
113
+ },
114
+ "0119": {
115
+ "de": "Gemeinderschaft",
116
+ "fr": "Responsable d'indivision",
117
+ "it": "Rappresentante dell'indivisione",
118
+ "en": "Undivided-estate representative",
119
+ },
120
+ "0151": {
121
+ "de": "Zweigniederlassung schweizerischer Gesellschaft",
122
+ "fr": "Succursale suisse inscrite au registre du commerce",
123
+ "it": "Succursale svizzera iscritta nel registro di commercio",
124
+ "en": "Branch of a Swiss company",
125
+ },
126
+ }
127
+
128
+ #: Common abbreviations by code, in the order German / French / Italian.
129
+ LEGAL_FORM_ABBREVIATIONS: dict[str, tuple[str, ...]] = {
130
+ "0103": ("KlG", "SNC", "SNC"),
131
+ "0104": ("KmG", "SC", "SAc"),
132
+ "0105": ("KmAG", "SCA", "SAcA"),
133
+ "0106": ("AG", "SA", "SA"),
134
+ "0107": ("GmbH", "Sàrl", "Sagl"),
135
+ "0115": ("SICAV", "SICAV", "SICAV"),
136
+ }
137
+
138
+
139
+ def legal_form_name(code: str, lang: str = "de") -> str:
140
+ """The name of legal-form *code* in *lang*, falling back to German.
141
+
142
+ Returns ``""`` for a code that is not part of eCH-0097.
143
+ """
144
+ labels = LEGAL_FORM_LABELS.get(str(code).strip())
145
+ if not labels:
146
+ return ""
147
+ return labels.get(lang) or labels["de"]
148
+
149
+
150
+ def legal_form_abbreviation(code: str, lang: str = "de") -> str:
151
+ """The usual abbreviation for *code* in *lang*, or ``""``.
152
+
153
+ Only the forms a company carries in its name have one, so a foundation or
154
+ an association returns ``""``.
155
+ """
156
+ abbreviations = LEGAL_FORM_ABBREVIATIONS.get(str(code).strip())
157
+ if not abbreviations:
158
+ return ""
159
+ index = {"de": 0, "fr": 1, "it": 2}.get(lang, 0)
160
+ return abbreviations[index]
zefix_parser/lindas.py ADDED
@@ -0,0 +1,445 @@
1
+ """SPARQL queries and result parsing for the Zefix dataset on LINDAS.
2
+
3
+ LINDAS is the Swiss federal linked-data service, and it publishes the whole
4
+ commercial register as RDF at ``https://ld.admin.ch/query``. No credentials
5
+ are needed. Nothing in this module opens a network connection: build a query,
6
+ send it however you like, hand the bytes back here.
7
+
8
+ The dataset has no modification-date predicate, so a server-side delta fetch
9
+ is impossible. Pagination runs by keyset over the entity URI, which is what
10
+ :func:`build_combined_page_query` builds.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import re
18
+ from dataclasses import replace
19
+
20
+ from ._labels import normalize_whitespace, pick_label, pick_label_with_language
21
+ from .identifiers import normalize_uid
22
+ from .schemas import ParserError, RegistryEntity
23
+
24
+ #: Bumped whenever a query template changes in a way that alters output.
25
+ QUERY_VERSION = "1"
26
+
27
+ PREFIXES = """\
28
+ PREFIX schema: <http://schema.org/>
29
+ PREFIX admin: <https://schema.ld.admin.ch/>
30
+ PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
31
+ """
32
+
33
+ _ENTITY_FIELDS = """\
34
+ SELECT ?entity ?legalName ?name ?uid ?chid ?ehraId
35
+ ?legalFormUri ?legalFormLabel
36
+ ?municipalityUri ?municipalityLabel
37
+ ?canton
38
+ ?streetAddress ?postalCode ?locality
39
+ ?purpose
40
+ """
41
+
42
+ _ENTITY_PATTERN = """\
43
+ ?entity schema:legalName ?legalName .
44
+
45
+ OPTIONAL {{ ?entity schema:name ?name . }}
46
+
47
+ OPTIONAL {{
48
+ ?entity schema:identifier ?uidNode .
49
+ ?uidNode schema:name "CompanyUID" ;
50
+ schema:value ?uid .
51
+ }}
52
+
53
+ OPTIONAL {{
54
+ ?entity schema:identifier ?chidNode .
55
+ ?chidNode schema:name "CompanyCHID" ;
56
+ schema:value ?chid .
57
+ }}
58
+
59
+ OPTIONAL {{
60
+ ?entity schema:identifier ?ehraIdNode .
61
+ ?ehraIdNode schema:name "CompanyEHRAID" ;
62
+ schema:value ?ehraId .
63
+ }}
64
+
65
+ OPTIONAL {{
66
+ ?entity schema:additionalType ?legalFormUri .
67
+ OPTIONAL {{ ?legalFormUri (schema:name|rdfs:label) ?legalFormLabel . }}
68
+ }}
69
+
70
+ OPTIONAL {{
71
+ ?entity admin:municipality ?municipalityUri .
72
+ ?municipalityUri schema:name ?municipalityLabel .
73
+ }}
74
+
75
+ OPTIONAL {{ ?entity schema:address/schema:addressRegion ?canton . }}
76
+ OPTIONAL {{ ?entity schema:address/schema:streetAddress ?streetAddress . }}
77
+ OPTIONAL {{ ?entity schema:address/schema:postalCode ?postalCode . }}
78
+ OPTIONAL {{ ?entity schema:address/schema:addressLocality ?locality . }}
79
+
80
+ OPTIONAL {{ ?entity schema:description ?purpose . }}
81
+ """
82
+
83
+ COUNT_QUERY = """\
84
+ {prefixes}
85
+ SELECT (COUNT(DISTINCT ?entity) AS ?count) WHERE {{
86
+ ?entity a admin:ZefixOrganisation .
87
+ }}
88
+ """
89
+
90
+ URI_PAGE_QUERY = """\
91
+ {prefixes}
92
+ SELECT DISTINCT ?entity WHERE {{
93
+ ?entity a admin:ZefixOrganisation .
94
+ {cursor_filter}
95
+ }}
96
+ ORDER BY STR(?entity)
97
+ LIMIT {limit}
98
+ """
99
+
100
+ DETAIL_QUERY = """\
101
+ {prefixes}
102
+ {fields}WHERE {{
103
+ VALUES ?entity {{ {entity_uris} }}
104
+
105
+ ?entity a admin:ZefixOrganisation .
106
+ {pattern}}}
107
+ """
108
+
109
+ DETAIL_BY_UID_QUERY = """\
110
+ {prefixes}
111
+ {fields}WHERE {{
112
+ VALUES ?uid {{ {uid_values} }}
113
+
114
+ ?uidNode schema:name "CompanyUID" ;
115
+ schema:value ?uid .
116
+
117
+ ?entity schema:identifier ?uidNode ;
118
+ a admin:ZefixOrganisation .
119
+ {pattern}}}
120
+ """
121
+
122
+ COMBINED_PAGE_QUERY = """\
123
+ {prefixes}
124
+ {fields}WHERE {{
125
+ {{
126
+ SELECT DISTINCT ?entity WHERE {{
127
+ ?entity a admin:ZefixOrganisation .
128
+ {cursor_filter}
129
+ }}
130
+ ORDER BY STR(?entity)
131
+ LIMIT {limit}
132
+ }}
133
+
134
+ {pattern}}}
135
+ """
136
+
137
+ _LEGAL_FORM_CODE = re.compile(r"/([^/]+)$")
138
+ _MUNICIPALITY_ID = re.compile(r"/(\d+)$")
139
+
140
+
141
+ def build_count_query() -> str:
142
+ """A query returning the number of companies in the dataset."""
143
+ return COUNT_QUERY.format(prefixes=PREFIXES)
144
+
145
+
146
+ def build_uri_page_query(*, cursor_after: str | None = None, limit: int = 500) -> str:
147
+ """A query for one keyset page of entity URIs.
148
+
149
+ Pass the last URI of the previous page as *cursor_after* to get the next
150
+ page. Omit it for the first page.
151
+ """
152
+ return URI_PAGE_QUERY.format(
153
+ prefixes=PREFIXES,
154
+ cursor_filter=_cursor_filter(cursor_after),
155
+ limit=_limit(limit),
156
+ )
157
+
158
+
159
+ def build_detail_query(entity_uris: list[str]) -> str:
160
+ """A query for the full record of each URI in *entity_uris*."""
161
+ values = " ".join(f"<{_escape_uri(uri)}>" for uri in entity_uris)
162
+ return DETAIL_QUERY.format(
163
+ prefixes=PREFIXES,
164
+ fields=_ENTITY_FIELDS,
165
+ entity_uris=values,
166
+ pattern=_ENTITY_PATTERN.format(),
167
+ )
168
+
169
+
170
+ def build_detail_query_by_uids(uids: list[str]) -> str:
171
+ """A query for the full record of each UID in *uids*.
172
+
173
+ LINDAS stores UIDs unpunctuated, so the values are normalized for you.
174
+ ``"CHE-123.456.789"`` and ``"CHE123456789"`` both match.
175
+ """
176
+ values = " ".join(f'"{_escape_literal(normalize_uid(uid))}"' for uid in uids)
177
+ return DETAIL_BY_UID_QUERY.format(
178
+ prefixes=PREFIXES,
179
+ fields=_ENTITY_FIELDS,
180
+ uid_values=values,
181
+ pattern=_ENTITY_PATTERN.format(),
182
+ )
183
+
184
+
185
+ def build_combined_page_query(
186
+ *, cursor_after: str | None = None, limit: int = 500
187
+ ) -> str:
188
+ """A query for one keyset page of full records, URIs and details in one round trip."""
189
+ return COMBINED_PAGE_QUERY.format(
190
+ prefixes=PREFIXES,
191
+ fields=_ENTITY_FIELDS,
192
+ cursor_filter=_cursor_filter(cursor_after),
193
+ limit=_limit(limit),
194
+ pattern=_ENTITY_PATTERN.format(),
195
+ )
196
+
197
+
198
+ def parse_sparql_json(content: bytes) -> list[dict]:
199
+ """The ``results.bindings`` list of a SPARQL JSON response.
200
+
201
+ Raises:
202
+ ParserError: if *content* is not a SPARQL JSON results document.
203
+ """
204
+ try:
205
+ data = json.loads(content)
206
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as exc:
207
+ raise ParserError(f"invalid SPARQL JSON: {exc}") from exc
208
+
209
+ if not isinstance(data, dict):
210
+ raise ParserError("SPARQL response is not an object")
211
+
212
+ results = data.get("results")
213
+ if not isinstance(results, dict):
214
+ raise ParserError("SPARQL response has no 'results' object")
215
+
216
+ bindings = results.get("bindings")
217
+ if not isinstance(bindings, list):
218
+ raise ParserError("SPARQL results have no 'bindings' list")
219
+
220
+ return bindings
221
+
222
+
223
+ def parse_count(content: bytes) -> int | None:
224
+ """The integer answer to :func:`build_count_query`, or ``None``."""
225
+ bindings = parse_sparql_json(content)
226
+ if not bindings:
227
+ return None
228
+ value = _binding_value(bindings[0].get("count"))
229
+ if value is None:
230
+ return None
231
+ try:
232
+ return int(value)
233
+ except ValueError:
234
+ return None
235
+
236
+
237
+ def parse_uri_page(content: bytes) -> list[str]:
238
+ """The entity URIs of a :func:`build_uri_page_query` response, deduplicated."""
239
+ uris: list[str] = []
240
+ seen: set[str] = set()
241
+ for row in parse_sparql_json(content):
242
+ uri = _binding_value(row.get("entity"))
243
+ if uri and uri not in seen:
244
+ uris.append(uri)
245
+ seen.add(uri)
246
+ return uris
247
+
248
+
249
+ def parse_entity_page(content: bytes) -> list[RegistryEntity]:
250
+ """The companies in a detail or combined-page response.
251
+
252
+ One company spans several rows, one per language-tagged literal, so rows
253
+ are grouped by entity URI and folded down. Entities lacking a legal name,
254
+ or carrying neither a UID nor an EHRAID, are dropped: there is nothing to
255
+ match them against, and the register emits a few.
256
+ """
257
+ grouped: dict[str, list[dict]] = {}
258
+ for row in parse_sparql_json(content):
259
+ uri = _binding_value(row.get("entity"))
260
+ if uri:
261
+ grouped.setdefault(uri, []).append(row)
262
+
263
+ entities = []
264
+ for uri, rows in grouped.items():
265
+ entity = _group_entity(uri, rows)
266
+ if entity is not None:
267
+ entities.append(entity)
268
+ return entities
269
+
270
+
271
+ def _cursor_filter(cursor_after: str | None) -> str:
272
+ if not cursor_after:
273
+ return ""
274
+ return f'FILTER (STR(?entity) > "{_escape_literal(cursor_after)}")'
275
+
276
+
277
+ def _limit(limit: int) -> int:
278
+ if limit < 1:
279
+ raise ValueError(f"limit must be at least 1, got {limit!r}")
280
+ return int(limit)
281
+
282
+
283
+ def _escape_literal(value: str) -> str:
284
+ return (
285
+ value.replace("\\", "\\\\")
286
+ .replace('"', '\\"')
287
+ .replace("\n", "\\n")
288
+ .replace("\r", "\\r")
289
+ )
290
+
291
+
292
+ def _escape_uri(uri: str) -> str:
293
+ if any(char in uri for char in '<>"{}|^`\\ \n\r'):
294
+ raise ValueError(f"entity URI contains an illegal character: {uri!r}")
295
+ return uri
296
+
297
+
298
+ def _binding_value(binding: dict | None) -> str | None:
299
+ if not binding:
300
+ return None
301
+ value = binding.get("value")
302
+ if value is None or value == "":
303
+ return None
304
+ return str(value)
305
+
306
+
307
+ def _binding_language(binding: dict | None) -> str:
308
+ if not binding:
309
+ return ""
310
+ return binding.get("xml:lang", "")
311
+
312
+
313
+ def _group_entity(uri: str, rows: list[dict]) -> RegistryEntity | None:
314
+ uid = chid = ehra_id = ""
315
+ legal_names: dict[str, str] = {}
316
+ legal_form_labels: dict[str, str] = {}
317
+ municipality_labels: dict[str, str] = {}
318
+ purposes: dict[str, str] = {}
319
+ alternate_names: set[str] = set()
320
+ legal_form_codes: set[str] = set()
321
+ municipality_ids: set[str] = set()
322
+ cantons: set[str] = set()
323
+ street_addresses: set[str] = set()
324
+ postal_codes: set[str] = set()
325
+ localities: set[str] = set()
326
+
327
+ for row in rows:
328
+ uid = uid or _binding_value(row.get("uid")) or ""
329
+ chid = chid or _binding_value(row.get("chid")) or ""
330
+ ehra_id = ehra_id or _binding_value(row.get("ehraId")) or ""
331
+
332
+ legal_name = _binding_value(row.get("legalName"))
333
+ if legal_name:
334
+ legal_names[_binding_language(row.get("legalName"))] = normalize_whitespace(
335
+ legal_name
336
+ )
337
+
338
+ name = _binding_value(row.get("name"))
339
+ if name:
340
+ alternate_names.add(normalize_whitespace(name))
341
+
342
+ legal_form_uri = _binding_value(row.get("legalFormUri"))
343
+ if legal_form_uri:
344
+ code = _code_from_uri(legal_form_uri, _LEGAL_FORM_CODE)
345
+ if code:
346
+ legal_form_codes.add(code)
347
+ label = _binding_value(row.get("legalFormLabel"))
348
+ if label:
349
+ legal_form_labels[_binding_language(row.get("legalFormLabel"))] = label
350
+
351
+ municipality_uri = _binding_value(row.get("municipalityUri"))
352
+ if municipality_uri:
353
+ municipality_id = _code_from_uri(municipality_uri, _MUNICIPALITY_ID)
354
+ if municipality_id:
355
+ municipality_ids.add(municipality_id)
356
+ label = _binding_value(row.get("municipalityLabel"))
357
+ if label:
358
+ municipality_labels[_binding_language(row.get("municipalityLabel"))] = (
359
+ label
360
+ )
361
+
362
+ canton = _binding_value(row.get("canton"))
363
+ if canton:
364
+ cantons.add(canton.strip().upper()[:2])
365
+
366
+ street_address = _binding_value(row.get("streetAddress"))
367
+ if street_address:
368
+ street_addresses.add(normalize_whitespace(street_address))
369
+
370
+ postal_code = _binding_value(row.get("postalCode"))
371
+ if postal_code:
372
+ postal_codes.add(postal_code.strip())
373
+
374
+ locality = _binding_value(row.get("locality"))
375
+ if locality:
376
+ localities.add(normalize_whitespace(locality))
377
+
378
+ purpose = _binding_value(row.get("purpose"))
379
+ if purpose:
380
+ purposes[_binding_language(row.get("purpose"))] = normalize_whitespace(
381
+ purpose
382
+ )
383
+
384
+ legal_name = pick_label(legal_names)
385
+ if not legal_name:
386
+ return None
387
+ if not uid and not ehra_id:
388
+ return None
389
+
390
+ every_name = set(legal_names.values()) | alternate_names
391
+ every_name.discard(legal_name)
392
+
393
+ purpose, purpose_language = pick_label_with_language(purposes)
394
+ entity = RegistryEntity(
395
+ zefix_uri=uri,
396
+ uid=uid,
397
+ chid=chid,
398
+ ehra_id=ehra_id,
399
+ legal_name=legal_name,
400
+ alternate_names=sorted(every_name),
401
+ legal_form_code=_first(legal_form_codes),
402
+ legal_form_name=pick_label(legal_form_labels),
403
+ municipality_id=_first(municipality_ids),
404
+ municipality_name=pick_label(municipality_labels),
405
+ canton=_first(cantons),
406
+ street_address=_first(street_addresses),
407
+ postal_code=_first(postal_codes),
408
+ locality=_first(localities),
409
+ purpose=purpose,
410
+ purpose_language=purpose_language,
411
+ )
412
+ return replace(entity, fingerprint=compute_fingerprint(entity))
413
+
414
+
415
+ def compute_fingerprint(entity: RegistryEntity) -> str:
416
+ """A stable digest of the fields that describe *entity* itself.
417
+
418
+ Two fetches of an unchanged company produce the same value, so you can
419
+ skip the write. The digest covers the descriptive fields only: ``chid``,
420
+ ``ehra_id`` and ``alternate_names`` sit outside it.
421
+ """
422
+ identity = {
423
+ "uri": entity.zefix_uri,
424
+ "uid": normalize_uid(entity.uid) if entity.uid else "",
425
+ "legal_name": entity.legal_name,
426
+ "legal_form_code": entity.legal_form_code,
427
+ "legal_form_name": entity.legal_form_name,
428
+ "municipality_id": entity.municipality_id,
429
+ "canton": entity.canton,
430
+ "street_address": entity.street_address,
431
+ "postal_code": entity.postal_code,
432
+ "locality": entity.locality,
433
+ "purpose": entity.purpose,
434
+ }
435
+ canonical = json.dumps(identity, sort_keys=True, ensure_ascii=True)
436
+ return hashlib.sha256(canonical.encode()).hexdigest()
437
+
438
+
439
+ def _code_from_uri(uri: str, pattern: re.Pattern) -> str:
440
+ match = pattern.search(uri)
441
+ return match.group(1) if match else ""
442
+
443
+
444
+ def _first(values: set[str]) -> str:
445
+ return sorted(values)[0] if values else ""
zefix_parser/py.typed ADDED
File without changes