zefix-parser 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.
- zefix_parser-0.1.0/PKG-INFO +272 -0
- zefix_parser-0.1.0/README.md +247 -0
- zefix_parser-0.1.0/pyproject.toml +36 -0
- zefix_parser-0.1.0/src/zefix_parser/__init__.py +103 -0
- zefix_parser-0.1.0/src/zefix_parser/_labels.py +37 -0
- zefix_parser-0.1.0/src/zefix_parser/client.py +368 -0
- zefix_parser-0.1.0/src/zefix_parser/identifiers.py +98 -0
- zefix_parser-0.1.0/src/zefix_parser/legal_forms.py +160 -0
- zefix_parser-0.1.0/src/zefix_parser/lindas.py +445 -0
- zefix_parser-0.1.0/src/zefix_parser/py.typed +0 -0
- zefix_parser-0.1.0/src/zefix_parser/rest.py +188 -0
- zefix_parser-0.1.0/src/zefix_parser/schemas.py +159 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zefix-parser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for Zefix, the Swiss Central Business Name Index (Zentraler Firmenindex)
|
|
5
|
+
Keywords: zefix,swiss,commercial-register,handelsregister,lindas,sparql,uid
|
|
6
|
+
Author: Prospex
|
|
7
|
+
Author-email: Prospex <hello@prospex.ch>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: pytest>=8 ; extra == 'dev'
|
|
16
|
+
Requires-Dist: httpx>=0.27 ; extra == 'dev'
|
|
17
|
+
Requires-Dist: httpx>=0.27 ; extra == 'http'
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Project-URL: Homepage, https://prospex.ch
|
|
20
|
+
Project-URL: Repository, https://github.com/prospex-ch/python-zefix-parser
|
|
21
|
+
Project-URL: Documentation, https://zefix-parser.readthedocs.io
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Provides-Extra: http
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# zefix-parser
|
|
27
|
+
|
|
28
|
+
[](https://zefix-parser.readthedocs.io/en/latest/)
|
|
29
|
+
|
|
30
|
+
Typed Python client for Zefix, the Swiss Central Business Name Index (Zentraler Firmenindex).
|
|
31
|
+
|
|
32
|
+
Covers both ways into the register: the open [LINDAS](https://lindas.admin.ch) SPARQL
|
|
33
|
+
dataset, which carries every company in Switzerland and needs no credentials, and the
|
|
34
|
+
[Zefix PublicREST API](https://www.zefix.admin.ch), which adds share capital, legal status,
|
|
35
|
+
auditors and corporate relations. Responses parse into plain dataclasses.
|
|
36
|
+
|
|
37
|
+
Built and maintained by [Prospex](https://prospex.ch), a Swiss B2B sales intelligence platform.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install zefix-parser
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To use the HTTP clients (for fetching from the live endpoints):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install zefix-parser[http]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
### Read the register from LINDAS
|
|
54
|
+
|
|
55
|
+
No credentials needed.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from zefix_parser.client import LindasClient
|
|
59
|
+
|
|
60
|
+
with LindasClient() as client:
|
|
61
|
+
print(client.count()) # 812,000 or so
|
|
62
|
+
|
|
63
|
+
for entity in client.iter_entities():
|
|
64
|
+
print(entity.legal_name, entity.uid, entity.canton, entity.legal_form_code)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or look up companies you already have UIDs for, in one request:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from zefix_parser.client import LindasClient
|
|
71
|
+
|
|
72
|
+
with LindasClient() as client:
|
|
73
|
+
entities = client.fetch_by_uids(["CHE-105.215.703", "CHE-411.462.297"])
|
|
74
|
+
|
|
75
|
+
for entity in entities:
|
|
76
|
+
print(entity.legal_name) # "Alpenblick Handel AG"
|
|
77
|
+
print(entity.purpose) # "Handel mit Waren aller Art"
|
|
78
|
+
print(entity.municipality_name, entity.canton)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Fetch details from the PublicREST API
|
|
82
|
+
|
|
83
|
+
The REST API is gated behind HTTP Basic auth. Credentials are issued on request by
|
|
84
|
+
`zefix@bj.admin.ch`.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from zefix_parser.client import ZefixRestClient
|
|
88
|
+
|
|
89
|
+
with ZefixRestClient(username="...", password="...") as client:
|
|
90
|
+
company = client.get_by_uid("CHE-105.215.703")
|
|
91
|
+
|
|
92
|
+
print(company.name) # "Alpenblick Handel AG"
|
|
93
|
+
print(company.capital_nominal) # Decimal("250000.00")
|
|
94
|
+
print(company.status) # "ACTIVE"
|
|
95
|
+
|
|
96
|
+
for auditor in company.audit_companies:
|
|
97
|
+
print(auditor.name, auditor.legal_seat)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Both clients rate-limit themselves to one request every half second and retry transient
|
|
101
|
+
failures with exponential backoff.
|
|
102
|
+
|
|
103
|
+
### Parse responses you already have
|
|
104
|
+
|
|
105
|
+
Every parser is importable without the `http` extra, so you can point them at recorded
|
|
106
|
+
responses or use your own HTTP library:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from zefix_parser import parse_company, parse_entity_page
|
|
110
|
+
|
|
111
|
+
company = parse_company(rest_json) # a dict from the REST API
|
|
112
|
+
entities = parse_entity_page(sparql_bytes) # a SPARQL JSON results document
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## The two access paths
|
|
116
|
+
|
|
117
|
+
| | LINDAS | PublicREST |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| Endpoint | `https://ld.admin.ch/query` | `https://www.zefix.admin.ch/ZefixPublicREST/api/v1` |
|
|
120
|
+
| Credentials | none | HTTP Basic |
|
|
121
|
+
| Shape | the whole register, keyset-paginated | one company per request |
|
|
122
|
+
| Carries | name, legal form, address, canton, purpose | the above plus capital, status, auditors, branches, mergers, former names |
|
|
123
|
+
| Class | `RegistryEntity` | `Company` |
|
|
124
|
+
|
|
125
|
+
LINDAS has no modification-date predicate, so a server-side delta fetch is impossible: you
|
|
126
|
+
either walk the whole register or drive updates from another change feed, such as
|
|
127
|
+
[SHAB publications](https://shab-parser.readthedocs.io).
|
|
128
|
+
|
|
129
|
+
## Data model
|
|
130
|
+
|
|
131
|
+
`parse_entity_page()` returns `RegistryEntity` objects:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class RegistryEntity:
|
|
136
|
+
zefix_uri: str
|
|
137
|
+
uid: str # CHE123456789, unpunctuated
|
|
138
|
+
chid: str
|
|
139
|
+
ehra_id: str
|
|
140
|
+
legal_name: str
|
|
141
|
+
alternate_names: list[str] # the other language variants
|
|
142
|
+
legal_form_code: str # eCH-0097, e.g. "0106"
|
|
143
|
+
legal_form_name: str
|
|
144
|
+
municipality_id: str # federal municipality number
|
|
145
|
+
municipality_name: str
|
|
146
|
+
canton: str
|
|
147
|
+
street_address: str
|
|
148
|
+
postal_code: str
|
|
149
|
+
locality: str
|
|
150
|
+
purpose: str
|
|
151
|
+
purpose_language: str
|
|
152
|
+
fingerprint: str # SHA-256 over the identity fields
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`parse_company()` returns `Company` objects:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Company:
|
|
160
|
+
name: str
|
|
161
|
+
ehraid: int
|
|
162
|
+
uid: str
|
|
163
|
+
chid: str
|
|
164
|
+
canton: str
|
|
165
|
+
status: str # "ACTIVE", "IN_LIQUIDATION", "DELETED"
|
|
166
|
+
capital_nominal: Decimal | None
|
|
167
|
+
capital_currency: str
|
|
168
|
+
deletion_date: date | None
|
|
169
|
+
cantonal_excerpt_web: str
|
|
170
|
+
head_offices: list[RelatedEntity]
|
|
171
|
+
further_head_offices: list[RelatedEntity]
|
|
172
|
+
branch_offices: list[RelatedEntity]
|
|
173
|
+
has_taken_over: list[RelatedEntity]
|
|
174
|
+
was_taken_over_by: list[RelatedEntity]
|
|
175
|
+
audit_companies: list[RelatedEntity]
|
|
176
|
+
old_names: list[OldName]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Identifiers
|
|
180
|
+
|
|
181
|
+
The same UID appears in three forms depending on where you read it, so the library
|
|
182
|
+
converts between them:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from zefix_parser import clean_uid, format_uid, is_valid_uid, normalize_uid
|
|
186
|
+
|
|
187
|
+
normalize_uid("CHE-105.215.703") # "CHE105215703" (LINDAS form)
|
|
188
|
+
format_uid("CHE105215703") # "CHE-105.215.703" (REST form)
|
|
189
|
+
clean_uid("VAT: CHE-109.807.630 MWST") # "CHE109807630"
|
|
190
|
+
is_valid_uid("CHE123456789") # False - the check digit fails
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`is_valid_uid()` runs the ISO 7064 mod 11-10 check digit. It is worth calling before you
|
|
194
|
+
spend a request: a mistyped UID can never match the register, and the register's own
|
|
195
|
+
placeholder `CHE123456789` fails it.
|
|
196
|
+
|
|
197
|
+
## Legal forms
|
|
198
|
+
|
|
199
|
+
Both registers identify a legal form by its four-digit eCH-0097 code, and the name differs
|
|
200
|
+
by language:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from zefix_parser import LegalForm, legal_form_abbreviation, legal_form_name
|
|
204
|
+
|
|
205
|
+
LegalForm.CORPORATION # "0106"
|
|
206
|
+
legal_form_name("0106", "de") # "Aktiengesellschaft"
|
|
207
|
+
legal_form_name("0106", "fr") # "Société anonyme"
|
|
208
|
+
legal_form_name("0106", "it") # "Società anonima"
|
|
209
|
+
legal_form_abbreviation("0107", "fr") # "Sàrl"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The full table is in the [legal forms reference](https://zefix-parser.readthedocs.io/en/latest/legal-forms.html).
|
|
213
|
+
|
|
214
|
+
## Former names
|
|
215
|
+
|
|
216
|
+
The register re-typesets a company's name whenever anything else about it changes, so
|
|
217
|
+
`company.old_names` fills up with entries that differ from the current name only in casing
|
|
218
|
+
or spacing: "QualiCasa AG" against "Qualicasa AG". Apply them and you retire the company's
|
|
219
|
+
live name. `meaningful_old_names()` drops them:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from zefix_parser import meaningful_old_names
|
|
223
|
+
|
|
224
|
+
for old in meaningful_old_names(company):
|
|
225
|
+
print(old.sequence_nr, old.name) # only the genuine renames, oldest first
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## API reference
|
|
229
|
+
|
|
230
|
+
### `zefix_parser.parse_entity_page(content: bytes) -> list[RegistryEntity]`
|
|
231
|
+
|
|
232
|
+
Parse a SPARQL JSON results document into companies. One company spans several rows, one
|
|
233
|
+
per language-tagged literal; rows are grouped by entity URI and folded down to a single
|
|
234
|
+
deterministic value per field.
|
|
235
|
+
|
|
236
|
+
### `zefix_parser.parse_company(data: dict | list) -> Company`
|
|
237
|
+
|
|
238
|
+
Parse one `/company/...` response. The API is inconsistent about whether a single company
|
|
239
|
+
comes back as an object or a one-element list; both work.
|
|
240
|
+
|
|
241
|
+
### `zefix_parser.build_combined_page_query(*, cursor_after=None, limit=500) -> str`
|
|
242
|
+
|
|
243
|
+
Build the SPARQL query for one keyset page of full records. Use this to run the crawl
|
|
244
|
+
yourself, outside `LindasClient`.
|
|
245
|
+
|
|
246
|
+
### `zefix_parser.client.LindasClient`
|
|
247
|
+
|
|
248
|
+
SPARQL client. `count()`, `iter_entities()`, `iter_pages()` (which yields `RawPage` objects
|
|
249
|
+
carrying the cursor, so a crawl can be resumed), `fetch_by_uids()`, `query()`.
|
|
250
|
+
|
|
251
|
+
### `zefix_parser.client.ZefixRestClient`
|
|
252
|
+
|
|
253
|
+
PublicREST client. `get_by_uid()`, `get_by_ehraid()`, `get_by_chid()`, `search()`,
|
|
254
|
+
`sogc_by_date()`, `sogc()`.
|
|
255
|
+
|
|
256
|
+
## Background
|
|
257
|
+
|
|
258
|
+
Zefix (the Zentraler Firmenindex, *index central des raisons de commerce*, *indice centrale
|
|
259
|
+
delle ditte*, officially the Swiss Central Business Name Index) is the federal index over
|
|
260
|
+
the 26 cantonal commercial registers: Handelsregister, registre du commerce, registro di
|
|
261
|
+
commercio. Every company registered in Switzerland appears in it, identified by a UID
|
|
262
|
+
(`CHE-123.456.789`), a CHID and an EHRAID.
|
|
263
|
+
|
|
264
|
+
The Federal Office of Justice publishes the index twice. LINDAS carries every company as
|
|
265
|
+
linked data, open to anyone, holding each company's current state. The PublicREST API holds
|
|
266
|
+
more per company, behind credentials, and answers one lookup at a time. Neither publishes a
|
|
267
|
+
change feed; for that you need the gazette, which is what
|
|
268
|
+
[shab-parser](https://shab-parser.readthedocs.io) reads.
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# zefix-parser
|
|
2
|
+
|
|
3
|
+
[](https://zefix-parser.readthedocs.io/en/latest/)
|
|
4
|
+
|
|
5
|
+
Typed Python client for Zefix, the Swiss Central Business Name Index (Zentraler Firmenindex).
|
|
6
|
+
|
|
7
|
+
Covers both ways into the register: the open [LINDAS](https://lindas.admin.ch) SPARQL
|
|
8
|
+
dataset, which carries every company in Switzerland and needs no credentials, and the
|
|
9
|
+
[Zefix PublicREST API](https://www.zefix.admin.ch), which adds share capital, legal status,
|
|
10
|
+
auditors and corporate relations. Responses parse into plain dataclasses.
|
|
11
|
+
|
|
12
|
+
Built and maintained by [Prospex](https://prospex.ch), a Swiss B2B sales intelligence platform.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install zefix-parser
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
To use the HTTP clients (for fetching from the live endpoints):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install zefix-parser[http]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
### Read the register from LINDAS
|
|
29
|
+
|
|
30
|
+
No credentials needed.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from zefix_parser.client import LindasClient
|
|
34
|
+
|
|
35
|
+
with LindasClient() as client:
|
|
36
|
+
print(client.count()) # 812,000 or so
|
|
37
|
+
|
|
38
|
+
for entity in client.iter_entities():
|
|
39
|
+
print(entity.legal_name, entity.uid, entity.canton, entity.legal_form_code)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or look up companies you already have UIDs for, in one request:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from zefix_parser.client import LindasClient
|
|
46
|
+
|
|
47
|
+
with LindasClient() as client:
|
|
48
|
+
entities = client.fetch_by_uids(["CHE-105.215.703", "CHE-411.462.297"])
|
|
49
|
+
|
|
50
|
+
for entity in entities:
|
|
51
|
+
print(entity.legal_name) # "Alpenblick Handel AG"
|
|
52
|
+
print(entity.purpose) # "Handel mit Waren aller Art"
|
|
53
|
+
print(entity.municipality_name, entity.canton)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Fetch details from the PublicREST API
|
|
57
|
+
|
|
58
|
+
The REST API is gated behind HTTP Basic auth. Credentials are issued on request by
|
|
59
|
+
`zefix@bj.admin.ch`.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from zefix_parser.client import ZefixRestClient
|
|
63
|
+
|
|
64
|
+
with ZefixRestClient(username="...", password="...") as client:
|
|
65
|
+
company = client.get_by_uid("CHE-105.215.703")
|
|
66
|
+
|
|
67
|
+
print(company.name) # "Alpenblick Handel AG"
|
|
68
|
+
print(company.capital_nominal) # Decimal("250000.00")
|
|
69
|
+
print(company.status) # "ACTIVE"
|
|
70
|
+
|
|
71
|
+
for auditor in company.audit_companies:
|
|
72
|
+
print(auditor.name, auditor.legal_seat)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Both clients rate-limit themselves to one request every half second and retry transient
|
|
76
|
+
failures with exponential backoff.
|
|
77
|
+
|
|
78
|
+
### Parse responses you already have
|
|
79
|
+
|
|
80
|
+
Every parser is importable without the `http` extra, so you can point them at recorded
|
|
81
|
+
responses or use your own HTTP library:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from zefix_parser import parse_company, parse_entity_page
|
|
85
|
+
|
|
86
|
+
company = parse_company(rest_json) # a dict from the REST API
|
|
87
|
+
entities = parse_entity_page(sparql_bytes) # a SPARQL JSON results document
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## The two access paths
|
|
91
|
+
|
|
92
|
+
| | LINDAS | PublicREST |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| Endpoint | `https://ld.admin.ch/query` | `https://www.zefix.admin.ch/ZefixPublicREST/api/v1` |
|
|
95
|
+
| Credentials | none | HTTP Basic |
|
|
96
|
+
| Shape | the whole register, keyset-paginated | one company per request |
|
|
97
|
+
| Carries | name, legal form, address, canton, purpose | the above plus capital, status, auditors, branches, mergers, former names |
|
|
98
|
+
| Class | `RegistryEntity` | `Company` |
|
|
99
|
+
|
|
100
|
+
LINDAS has no modification-date predicate, so a server-side delta fetch is impossible: you
|
|
101
|
+
either walk the whole register or drive updates from another change feed, such as
|
|
102
|
+
[SHAB publications](https://shab-parser.readthedocs.io).
|
|
103
|
+
|
|
104
|
+
## Data model
|
|
105
|
+
|
|
106
|
+
`parse_entity_page()` returns `RegistryEntity` objects:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class RegistryEntity:
|
|
111
|
+
zefix_uri: str
|
|
112
|
+
uid: str # CHE123456789, unpunctuated
|
|
113
|
+
chid: str
|
|
114
|
+
ehra_id: str
|
|
115
|
+
legal_name: str
|
|
116
|
+
alternate_names: list[str] # the other language variants
|
|
117
|
+
legal_form_code: str # eCH-0097, e.g. "0106"
|
|
118
|
+
legal_form_name: str
|
|
119
|
+
municipality_id: str # federal municipality number
|
|
120
|
+
municipality_name: str
|
|
121
|
+
canton: str
|
|
122
|
+
street_address: str
|
|
123
|
+
postal_code: str
|
|
124
|
+
locality: str
|
|
125
|
+
purpose: str
|
|
126
|
+
purpose_language: str
|
|
127
|
+
fingerprint: str # SHA-256 over the identity fields
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`parse_company()` returns `Company` objects:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Company:
|
|
135
|
+
name: str
|
|
136
|
+
ehraid: int
|
|
137
|
+
uid: str
|
|
138
|
+
chid: str
|
|
139
|
+
canton: str
|
|
140
|
+
status: str # "ACTIVE", "IN_LIQUIDATION", "DELETED"
|
|
141
|
+
capital_nominal: Decimal | None
|
|
142
|
+
capital_currency: str
|
|
143
|
+
deletion_date: date | None
|
|
144
|
+
cantonal_excerpt_web: str
|
|
145
|
+
head_offices: list[RelatedEntity]
|
|
146
|
+
further_head_offices: list[RelatedEntity]
|
|
147
|
+
branch_offices: list[RelatedEntity]
|
|
148
|
+
has_taken_over: list[RelatedEntity]
|
|
149
|
+
was_taken_over_by: list[RelatedEntity]
|
|
150
|
+
audit_companies: list[RelatedEntity]
|
|
151
|
+
old_names: list[OldName]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Identifiers
|
|
155
|
+
|
|
156
|
+
The same UID appears in three forms depending on where you read it, so the library
|
|
157
|
+
converts between them:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from zefix_parser import clean_uid, format_uid, is_valid_uid, normalize_uid
|
|
161
|
+
|
|
162
|
+
normalize_uid("CHE-105.215.703") # "CHE105215703" (LINDAS form)
|
|
163
|
+
format_uid("CHE105215703") # "CHE-105.215.703" (REST form)
|
|
164
|
+
clean_uid("VAT: CHE-109.807.630 MWST") # "CHE109807630"
|
|
165
|
+
is_valid_uid("CHE123456789") # False - the check digit fails
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`is_valid_uid()` runs the ISO 7064 mod 11-10 check digit. It is worth calling before you
|
|
169
|
+
spend a request: a mistyped UID can never match the register, and the register's own
|
|
170
|
+
placeholder `CHE123456789` fails it.
|
|
171
|
+
|
|
172
|
+
## Legal forms
|
|
173
|
+
|
|
174
|
+
Both registers identify a legal form by its four-digit eCH-0097 code, and the name differs
|
|
175
|
+
by language:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from zefix_parser import LegalForm, legal_form_abbreviation, legal_form_name
|
|
179
|
+
|
|
180
|
+
LegalForm.CORPORATION # "0106"
|
|
181
|
+
legal_form_name("0106", "de") # "Aktiengesellschaft"
|
|
182
|
+
legal_form_name("0106", "fr") # "Société anonyme"
|
|
183
|
+
legal_form_name("0106", "it") # "Società anonima"
|
|
184
|
+
legal_form_abbreviation("0107", "fr") # "Sàrl"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The full table is in the [legal forms reference](https://zefix-parser.readthedocs.io/en/latest/legal-forms.html).
|
|
188
|
+
|
|
189
|
+
## Former names
|
|
190
|
+
|
|
191
|
+
The register re-typesets a company's name whenever anything else about it changes, so
|
|
192
|
+
`company.old_names` fills up with entries that differ from the current name only in casing
|
|
193
|
+
or spacing: "QualiCasa AG" against "Qualicasa AG". Apply them and you retire the company's
|
|
194
|
+
live name. `meaningful_old_names()` drops them:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from zefix_parser import meaningful_old_names
|
|
198
|
+
|
|
199
|
+
for old in meaningful_old_names(company):
|
|
200
|
+
print(old.sequence_nr, old.name) # only the genuine renames, oldest first
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## API reference
|
|
204
|
+
|
|
205
|
+
### `zefix_parser.parse_entity_page(content: bytes) -> list[RegistryEntity]`
|
|
206
|
+
|
|
207
|
+
Parse a SPARQL JSON results document into companies. One company spans several rows, one
|
|
208
|
+
per language-tagged literal; rows are grouped by entity URI and folded down to a single
|
|
209
|
+
deterministic value per field.
|
|
210
|
+
|
|
211
|
+
### `zefix_parser.parse_company(data: dict | list) -> Company`
|
|
212
|
+
|
|
213
|
+
Parse one `/company/...` response. The API is inconsistent about whether a single company
|
|
214
|
+
comes back as an object or a one-element list; both work.
|
|
215
|
+
|
|
216
|
+
### `zefix_parser.build_combined_page_query(*, cursor_after=None, limit=500) -> str`
|
|
217
|
+
|
|
218
|
+
Build the SPARQL query for one keyset page of full records. Use this to run the crawl
|
|
219
|
+
yourself, outside `LindasClient`.
|
|
220
|
+
|
|
221
|
+
### `zefix_parser.client.LindasClient`
|
|
222
|
+
|
|
223
|
+
SPARQL client. `count()`, `iter_entities()`, `iter_pages()` (which yields `RawPage` objects
|
|
224
|
+
carrying the cursor, so a crawl can be resumed), `fetch_by_uids()`, `query()`.
|
|
225
|
+
|
|
226
|
+
### `zefix_parser.client.ZefixRestClient`
|
|
227
|
+
|
|
228
|
+
PublicREST client. `get_by_uid()`, `get_by_ehraid()`, `get_by_chid()`, `search()`,
|
|
229
|
+
`sogc_by_date()`, `sogc()`.
|
|
230
|
+
|
|
231
|
+
## Background
|
|
232
|
+
|
|
233
|
+
Zefix (the Zentraler Firmenindex, *index central des raisons de commerce*, *indice centrale
|
|
234
|
+
delle ditte*, officially the Swiss Central Business Name Index) is the federal index over
|
|
235
|
+
the 26 cantonal commercial registers: Handelsregister, registre du commerce, registro di
|
|
236
|
+
commercio. Every company registered in Switzerland appears in it, identified by a UID
|
|
237
|
+
(`CHE-123.456.789`), a CHID and an EHRAID.
|
|
238
|
+
|
|
239
|
+
The Federal Office of Justice publishes the index twice. LINDAS carries every company as
|
|
240
|
+
linked data, open to anyone, holding each company's current state. The PublicREST API holds
|
|
241
|
+
more per company, behind credentials, and answers one lookup at a time. Neither publishes a
|
|
242
|
+
change feed; for that you need the gazette, which is what
|
|
243
|
+
[shab-parser](https://shab-parser.readthedocs.io) reads.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zefix-parser"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed Python client for Zefix, the Swiss Central Business Name Index (Zentraler Firmenindex)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Prospex", email = "hello@prospex.ch" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
keywords = ["zefix", "swiss", "commercial-register", "handelsregister", "lindas", "sparql", "uid"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Office/Business :: Financial",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
http = ["httpx>=0.27"]
|
|
24
|
+
dev = ["pytest>=8", "httpx>=0.27"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://prospex.ch"
|
|
28
|
+
Repository = "https://github.com/prospex-ch/python-zefix-parser"
|
|
29
|
+
Documentation = "https://zefix-parser.readthedocs.io"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
@@ -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 "", ""
|