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