shab-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.
- shab_parser-0.1.0/PKG-INFO +165 -0
- shab_parser-0.1.0/README.md +140 -0
- shab_parser-0.1.0/pyproject.toml +36 -0
- shab_parser-0.1.0/src/shab_parser/__init__.py +39 -0
- shab_parser-0.1.0/src/shab_parser/_xml_utils.py +26 -0
- shab_parser-0.1.0/src/shab_parser/client.py +210 -0
- shab_parser-0.1.0/src/shab_parser/parser.py +332 -0
- shab_parser-0.1.0/src/shab_parser/py.typed +0 -0
- shab_parser-0.1.0/src/shab_parser/schemas.py +127 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shab-parser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parse publications from the Swiss Official Gazette of Commerce (SHAB/SOGC/FUSC)
|
|
5
|
+
Keywords: shab,sogc,fusc,swiss,commercial-register,handelsregister
|
|
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 :: Text Processing :: Markup :: XML
|
|
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/ssidorenko/python-shab-parser
|
|
21
|
+
Project-URL: Documentation, https://shab-parser.readthedocs.io
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Provides-Extra: http
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# shab-parser
|
|
27
|
+
|
|
28
|
+
[](https://shab-parser.readthedocs.io/en/latest/)
|
|
29
|
+
|
|
30
|
+
Typed Python client for the Swiss Official Gazette of Commerce (SHAB/SOGC/FUSC).
|
|
31
|
+
|
|
32
|
+
Fetches publications from the [Amtsblattportal](https://amtsblattportal.ch) public API,
|
|
33
|
+
parses the XML into dataclasses, and classifies each publication into structured events
|
|
34
|
+
(incorporation, seat move, capital increase, deletion, and others).
|
|
35
|
+
|
|
36
|
+
Built and maintained by [Prospex](https://prospex.ch), a Swiss B2B sales intelligence platform.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install shab-parser
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To use the HTTP client (for fetching from the live API):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install shab-parser[http]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
### Parse XML you already have
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from shab_parser import parse_xml
|
|
56
|
+
|
|
57
|
+
with open("publication.xml", "rb") as f:
|
|
58
|
+
pub = parse_xml(f.read())
|
|
59
|
+
|
|
60
|
+
print(pub.company_name) # "Alpenblick Handel AG"
|
|
61
|
+
print(pub.uid) # "CHE-123.456.789"
|
|
62
|
+
print(pub.canton) # "ZH"
|
|
63
|
+
|
|
64
|
+
for event in pub.events:
|
|
65
|
+
print(event.event_type, event.effective_date, event.payload)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Fetch and parse from the API
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from datetime import date
|
|
72
|
+
from shab_parser.client import ShabClient
|
|
73
|
+
from shab_parser import parse
|
|
74
|
+
|
|
75
|
+
with ShabClient() as client:
|
|
76
|
+
refs = client.discover(date(2026, 6, 15), date(2026, 6, 15))
|
|
77
|
+
|
|
78
|
+
for ref in refs[:5]:
|
|
79
|
+
raw = client.fetch(ref)
|
|
80
|
+
pub = parse(raw)
|
|
81
|
+
print(f"{pub.company_name}: {[e.event_type.value for e in pub.events]}")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The client rate-limits itself to one request per second and retries transient failures
|
|
85
|
+
with exponential backoff.
|
|
86
|
+
|
|
87
|
+
## Event types
|
|
88
|
+
|
|
89
|
+
The parser classifies each publication into one or more of these events,
|
|
90
|
+
based on the machine-readable XML fields (not free text):
|
|
91
|
+
|
|
92
|
+
| Event | Sub-rubric | Trigger |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `INCORPORATION` | HR01 | `<registration>true</registration>` |
|
|
95
|
+
| `SEAT_MOVED` | HR02 | Different seat in commonsNew vs. commonsActual |
|
|
96
|
+
| `ADDRESS_CHANGED` | HR02 | `<addressChanged>true</addressChanged>` |
|
|
97
|
+
| `NAME_CHANGED` | HR02 | Different company name in commonsNew vs. commonsActual |
|
|
98
|
+
| `CAPITAL_INCREASED` | HR02 | Structured nominal comparison, phrase fallback |
|
|
99
|
+
| `LIQUIDATION` | any | Dissolution flags or "in Liquidation" added to name |
|
|
100
|
+
| `DELETED` | HR03 | `<delete>` block with deletion date |
|
|
101
|
+
|
|
102
|
+
## Data model
|
|
103
|
+
|
|
104
|
+
`parse()` and `parse_xml()` return a `Publication` dataclass:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Publication:
|
|
109
|
+
external_id: str
|
|
110
|
+
publication_date: date
|
|
111
|
+
language: str # "de", "fr", or "it"
|
|
112
|
+
source_url: str
|
|
113
|
+
company_name: str
|
|
114
|
+
raw_text: str
|
|
115
|
+
sub_rubric: str # "HR01", "HR02", or "HR03"
|
|
116
|
+
effective_date: date | None
|
|
117
|
+
canton: str | None
|
|
118
|
+
uid: str | None # CHE-xxx.xxx.xxx
|
|
119
|
+
legal_form_code: str | None
|
|
120
|
+
publication_state: str # "PUBLISHED" or "CANCELLED"
|
|
121
|
+
events: list[Event]
|
|
122
|
+
company_new: Company | None
|
|
123
|
+
company_actual: Company | None
|
|
124
|
+
capital_new: float | None
|
|
125
|
+
capital_actual: float | None
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## API reference
|
|
129
|
+
|
|
130
|
+
### `shab_parser.parse(raw: RawResponse) -> Publication`
|
|
131
|
+
|
|
132
|
+
Parse a `RawResponse` (as returned by `ShabClient.fetch()`) into a `Publication`.
|
|
133
|
+
|
|
134
|
+
### `shab_parser.parse_xml(xml_bytes, *, source_url="", ref_state=None) -> Publication`
|
|
135
|
+
|
|
136
|
+
Parse raw XML bytes directly. Use this when you already have the XML
|
|
137
|
+
and don't need the HTTP client.
|
|
138
|
+
|
|
139
|
+
### `shab_parser.client.ShabClient`
|
|
140
|
+
|
|
141
|
+
HTTP client for the Amtsblattportal API. Requires the `http` extra.
|
|
142
|
+
|
|
143
|
+
- `discover(start, end)` lists all HR publications in a date range.
|
|
144
|
+
Queries both `PUBLISHED` and `CANCELLED` states, deduplicating by external ID.
|
|
145
|
+
- `fetch(ref)` downloads one publication's full XML.
|
|
146
|
+
|
|
147
|
+
### `shab_parser.client.parse_bulk_export(xml_bytes) -> (list[PublicationRef], int)`
|
|
148
|
+
|
|
149
|
+
Parse a bulk-export list page into publication references and a total count.
|
|
150
|
+
Useful if you handle pagination yourself.
|
|
151
|
+
|
|
152
|
+
## Background
|
|
153
|
+
|
|
154
|
+
SHAB (Schweizerisches Handelsamtsblatt) is the official gazette where Swiss commercial
|
|
155
|
+
register entries are published. Every new company, every seat change, every capital
|
|
156
|
+
increase, every deletion passes through it. The same publication appears in German, French,
|
|
157
|
+
and Italian, each under a different namespace (`HR01:`, `HR02:`, `HR03:`), but with
|
|
158
|
+
identical XML structure.
|
|
159
|
+
|
|
160
|
+
This library handles the namespace differences transparently using ElementPath's `{*}`
|
|
161
|
+
wildcard, so you get the same parsed output regardless of language.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# shab-parser
|
|
2
|
+
|
|
3
|
+
[](https://shab-parser.readthedocs.io/en/latest/)
|
|
4
|
+
|
|
5
|
+
Typed Python client for the Swiss Official Gazette of Commerce (SHAB/SOGC/FUSC).
|
|
6
|
+
|
|
7
|
+
Fetches publications from the [Amtsblattportal](https://amtsblattportal.ch) public API,
|
|
8
|
+
parses the XML into dataclasses, and classifies each publication into structured events
|
|
9
|
+
(incorporation, seat move, capital increase, deletion, and others).
|
|
10
|
+
|
|
11
|
+
Built and maintained by [Prospex](https://prospex.ch), a Swiss B2B sales intelligence platform.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install shab-parser
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To use the HTTP client (for fetching from the live API):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install shab-parser[http]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### Parse XML you already have
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from shab_parser import parse_xml
|
|
31
|
+
|
|
32
|
+
with open("publication.xml", "rb") as f:
|
|
33
|
+
pub = parse_xml(f.read())
|
|
34
|
+
|
|
35
|
+
print(pub.company_name) # "Alpenblick Handel AG"
|
|
36
|
+
print(pub.uid) # "CHE-123.456.789"
|
|
37
|
+
print(pub.canton) # "ZH"
|
|
38
|
+
|
|
39
|
+
for event in pub.events:
|
|
40
|
+
print(event.event_type, event.effective_date, event.payload)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Fetch and parse from the API
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from datetime import date
|
|
47
|
+
from shab_parser.client import ShabClient
|
|
48
|
+
from shab_parser import parse
|
|
49
|
+
|
|
50
|
+
with ShabClient() as client:
|
|
51
|
+
refs = client.discover(date(2026, 6, 15), date(2026, 6, 15))
|
|
52
|
+
|
|
53
|
+
for ref in refs[:5]:
|
|
54
|
+
raw = client.fetch(ref)
|
|
55
|
+
pub = parse(raw)
|
|
56
|
+
print(f"{pub.company_name}: {[e.event_type.value for e in pub.events]}")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The client rate-limits itself to one request per second and retries transient failures
|
|
60
|
+
with exponential backoff.
|
|
61
|
+
|
|
62
|
+
## Event types
|
|
63
|
+
|
|
64
|
+
The parser classifies each publication into one or more of these events,
|
|
65
|
+
based on the machine-readable XML fields (not free text):
|
|
66
|
+
|
|
67
|
+
| Event | Sub-rubric | Trigger |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `INCORPORATION` | HR01 | `<registration>true</registration>` |
|
|
70
|
+
| `SEAT_MOVED` | HR02 | Different seat in commonsNew vs. commonsActual |
|
|
71
|
+
| `ADDRESS_CHANGED` | HR02 | `<addressChanged>true</addressChanged>` |
|
|
72
|
+
| `NAME_CHANGED` | HR02 | Different company name in commonsNew vs. commonsActual |
|
|
73
|
+
| `CAPITAL_INCREASED` | HR02 | Structured nominal comparison, phrase fallback |
|
|
74
|
+
| `LIQUIDATION` | any | Dissolution flags or "in Liquidation" added to name |
|
|
75
|
+
| `DELETED` | HR03 | `<delete>` block with deletion date |
|
|
76
|
+
|
|
77
|
+
## Data model
|
|
78
|
+
|
|
79
|
+
`parse()` and `parse_xml()` return a `Publication` dataclass:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Publication:
|
|
84
|
+
external_id: str
|
|
85
|
+
publication_date: date
|
|
86
|
+
language: str # "de", "fr", or "it"
|
|
87
|
+
source_url: str
|
|
88
|
+
company_name: str
|
|
89
|
+
raw_text: str
|
|
90
|
+
sub_rubric: str # "HR01", "HR02", or "HR03"
|
|
91
|
+
effective_date: date | None
|
|
92
|
+
canton: str | None
|
|
93
|
+
uid: str | None # CHE-xxx.xxx.xxx
|
|
94
|
+
legal_form_code: str | None
|
|
95
|
+
publication_state: str # "PUBLISHED" or "CANCELLED"
|
|
96
|
+
events: list[Event]
|
|
97
|
+
company_new: Company | None
|
|
98
|
+
company_actual: Company | None
|
|
99
|
+
capital_new: float | None
|
|
100
|
+
capital_actual: float | None
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## API reference
|
|
104
|
+
|
|
105
|
+
### `shab_parser.parse(raw: RawResponse) -> Publication`
|
|
106
|
+
|
|
107
|
+
Parse a `RawResponse` (as returned by `ShabClient.fetch()`) into a `Publication`.
|
|
108
|
+
|
|
109
|
+
### `shab_parser.parse_xml(xml_bytes, *, source_url="", ref_state=None) -> Publication`
|
|
110
|
+
|
|
111
|
+
Parse raw XML bytes directly. Use this when you already have the XML
|
|
112
|
+
and don't need the HTTP client.
|
|
113
|
+
|
|
114
|
+
### `shab_parser.client.ShabClient`
|
|
115
|
+
|
|
116
|
+
HTTP client for the Amtsblattportal API. Requires the `http` extra.
|
|
117
|
+
|
|
118
|
+
- `discover(start, end)` lists all HR publications in a date range.
|
|
119
|
+
Queries both `PUBLISHED` and `CANCELLED` states, deduplicating by external ID.
|
|
120
|
+
- `fetch(ref)` downloads one publication's full XML.
|
|
121
|
+
|
|
122
|
+
### `shab_parser.client.parse_bulk_export(xml_bytes) -> (list[PublicationRef], int)`
|
|
123
|
+
|
|
124
|
+
Parse a bulk-export list page into publication references and a total count.
|
|
125
|
+
Useful if you handle pagination yourself.
|
|
126
|
+
|
|
127
|
+
## Background
|
|
128
|
+
|
|
129
|
+
SHAB (Schweizerisches Handelsamtsblatt) is the official gazette where Swiss commercial
|
|
130
|
+
register entries are published. Every new company, every seat change, every capital
|
|
131
|
+
increase, every deletion passes through it. The same publication appears in German, French,
|
|
132
|
+
and Italian, each under a different namespace (`HR01:`, `HR02:`, `HR03:`), but with
|
|
133
|
+
identical XML structure.
|
|
134
|
+
|
|
135
|
+
This library handles the namespace differences transparently using ElementPath's `{*}`
|
|
136
|
+
wildcard, so you get the same parsed output regardless of language.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shab-parser"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Parse publications from the Swiss Official Gazette of Commerce (SHAB/SOGC/FUSC)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Prospex", email = "hello@prospex.ch" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
keywords = ["shab", "sogc", "fusc", "swiss", "commercial-register", "handelsregister"]
|
|
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 :: Text Processing :: Markup :: XML",
|
|
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/ssidorenko/python-shab-parser"
|
|
29
|
+
Documentation = "https://shab-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,39 @@
|
|
|
1
|
+
"""Parse publications from the Swiss Official Gazette of Commerce (SHAB/SOGC/FUSC)."""
|
|
2
|
+
|
|
3
|
+
from .schemas import (
|
|
4
|
+
Company,
|
|
5
|
+
DateRange,
|
|
6
|
+
Event,
|
|
7
|
+
EventType,
|
|
8
|
+
Language,
|
|
9
|
+
ParserError,
|
|
10
|
+
Publication,
|
|
11
|
+
PublicationRef,
|
|
12
|
+
PublicationState,
|
|
13
|
+
RawResponse,
|
|
14
|
+
RetryableError,
|
|
15
|
+
ShabError,
|
|
16
|
+
SubRubric,
|
|
17
|
+
TransportError,
|
|
18
|
+
)
|
|
19
|
+
from .parser import parse, parse_xml, PARSER_VERSION
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Company",
|
|
23
|
+
"DateRange",
|
|
24
|
+
"Event",
|
|
25
|
+
"EventType",
|
|
26
|
+
"Language",
|
|
27
|
+
"PARSER_VERSION",
|
|
28
|
+
"ParserError",
|
|
29
|
+
"Publication",
|
|
30
|
+
"PublicationRef",
|
|
31
|
+
"PublicationState",
|
|
32
|
+
"RawResponse",
|
|
33
|
+
"RetryableError",
|
|
34
|
+
"ShabError",
|
|
35
|
+
"SubRubric",
|
|
36
|
+
"TransportError",
|
|
37
|
+
"parse",
|
|
38
|
+
"parse_xml",
|
|
39
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Namespace-agnostic helpers for SHAB XML feeds.
|
|
2
|
+
|
|
3
|
+
SHAB publications use a different namespace per sub-rubric (HR01/HR02/HR03) and
|
|
4
|
+
the bulk export uses its own. The ``{*}`` wildcard in ElementPath matches any
|
|
5
|
+
namespace, so a single path works across all of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from xml.etree import ElementTree as ET
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def child_text(elem: ET.Element | None, path: str) -> str | None:
|
|
14
|
+
"""Stripped text of the first element matching *path*, or ``None``."""
|
|
15
|
+
if elem is None:
|
|
16
|
+
return None
|
|
17
|
+
found = elem.find(path)
|
|
18
|
+
if found is None or found.text is None:
|
|
19
|
+
return None
|
|
20
|
+
text = found.text.strip()
|
|
21
|
+
return text or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_true(elem: ET.Element | None, path: str) -> bool:
|
|
25
|
+
"""True iff *path* resolves to an element whose text is ``"true"``."""
|
|
26
|
+
return child_text(elem, path) == "true"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""HTTP client for the Amtsblattportal public API.
|
|
2
|
+
|
|
3
|
+
Requires the ``http`` extra (``pip install shab-parser[http]``), which pulls in
|
|
4
|
+
`httpx <https://www.python-httpx.org/>`_.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from datetime import date, datetime, timezone
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
from .schemas import (
|
|
14
|
+
DateRange,
|
|
15
|
+
PublicationRef,
|
|
16
|
+
PublicationState,
|
|
17
|
+
RawResponse,
|
|
18
|
+
RetryableError,
|
|
19
|
+
TransportError,
|
|
20
|
+
)
|
|
21
|
+
from ._xml_utils import child_text
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://amtsblattportal.ch/api/v1"
|
|
24
|
+
|
|
25
|
+
MAX_PAGE_SIZE = 2000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_bulk_export(xml_bytes: bytes) -> tuple[list[PublicationRef], int]:
|
|
29
|
+
"""Parse a bulk-export list page into refs and the total count."""
|
|
30
|
+
try:
|
|
31
|
+
root = ET.fromstring(xml_bytes)
|
|
32
|
+
except ET.ParseError as exc:
|
|
33
|
+
raise TransportError(f"invalid bulk-export XML: {exc}") from exc
|
|
34
|
+
|
|
35
|
+
total = int(child_text(root, "{*}total") or "0")
|
|
36
|
+
refs: list[PublicationRef] = []
|
|
37
|
+
for pub in root.findall("{*}publication"):
|
|
38
|
+
url = pub.get("ref")
|
|
39
|
+
meta = pub.find("{*}meta")
|
|
40
|
+
external_id = child_text(meta, "{*}id")
|
|
41
|
+
pub_date = child_text(meta, "{*}publicationDate")
|
|
42
|
+
if not (url and external_id and pub_date):
|
|
43
|
+
continue
|
|
44
|
+
refs.append(
|
|
45
|
+
PublicationRef(
|
|
46
|
+
external_id=external_id,
|
|
47
|
+
publication_date=date.fromisoformat(pub_date),
|
|
48
|
+
language=(child_text(meta, "{*}language") or "").lower(),
|
|
49
|
+
url=url,
|
|
50
|
+
publication_state=(
|
|
51
|
+
child_text(meta, "{*}publicationState")
|
|
52
|
+
or PublicationState.PUBLISHED
|
|
53
|
+
).upper(),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return refs, total
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ShabClient:
|
|
60
|
+
"""Polite HTTP client for the SHAB publication API.
|
|
61
|
+
|
|
62
|
+
Rate-limits requests to at most one per *min_interval* seconds and retries
|
|
63
|
+
transient failures with exponential backoff.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
base_url: Root of the Amtsblattportal API.
|
|
67
|
+
min_interval: Minimum seconds between requests (rate limit).
|
|
68
|
+
timeout: HTTP timeout in seconds.
|
|
69
|
+
page_size: Publications per page when listing (max 2000).
|
|
70
|
+
max_retries: How many times to retry a transient failure.
|
|
71
|
+
user_agent: User-Agent header sent with every request.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
*,
|
|
78
|
+
min_interval: float = 1.0,
|
|
79
|
+
timeout: float = 30.0,
|
|
80
|
+
page_size: int = 500,
|
|
81
|
+
max_retries: int = 4,
|
|
82
|
+
user_agent: str = "python-shab-parser/0.1",
|
|
83
|
+
) -> None:
|
|
84
|
+
try:
|
|
85
|
+
import httpx
|
|
86
|
+
except ImportError:
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"httpx is required for ShabClient. "
|
|
89
|
+
"Install it with: pip install shab-parser[http]"
|
|
90
|
+
) from None
|
|
91
|
+
|
|
92
|
+
if not 1 <= page_size <= MAX_PAGE_SIZE:
|
|
93
|
+
raise ValueError(f"page_size must be in 1..{MAX_PAGE_SIZE}, got {page_size!r}")
|
|
94
|
+
|
|
95
|
+
self.base_url = base_url.rstrip("/")
|
|
96
|
+
self.min_interval = min_interval
|
|
97
|
+
self.page_size = page_size
|
|
98
|
+
self.max_retries = max_retries
|
|
99
|
+
self._last_request_at = 0.0
|
|
100
|
+
self._client = httpx.Client(
|
|
101
|
+
timeout=timeout, headers={"User-Agent": user_agent}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _throttle(self) -> None:
|
|
105
|
+
elapsed = time.monotonic() - self._last_request_at
|
|
106
|
+
if elapsed < self.min_interval:
|
|
107
|
+
time.sleep(self.min_interval - elapsed)
|
|
108
|
+
self._last_request_at = time.monotonic()
|
|
109
|
+
|
|
110
|
+
def _get(self, url: str, params: dict | None = None):
|
|
111
|
+
import httpx
|
|
112
|
+
|
|
113
|
+
self._throttle()
|
|
114
|
+
try:
|
|
115
|
+
response = self._client.get(url, params=params)
|
|
116
|
+
except httpx.TransportError as exc:
|
|
117
|
+
raise RetryableError(str(exc)) from exc
|
|
118
|
+
|
|
119
|
+
if response.status_code == 429 or 500 <= response.status_code < 600:
|
|
120
|
+
raise RetryableError(f"HTTP {response.status_code} for {url}")
|
|
121
|
+
if response.status_code >= 400:
|
|
122
|
+
raise TransportError(f"HTTP {response.status_code} for {url}")
|
|
123
|
+
return response
|
|
124
|
+
|
|
125
|
+
def _get_with_retry(self, url: str, params: dict | None = None):
|
|
126
|
+
last_exc: Exception | None = None
|
|
127
|
+
backoff = 0.5
|
|
128
|
+
for attempt in range(self.max_retries + 1):
|
|
129
|
+
try:
|
|
130
|
+
return self._get(url, params)
|
|
131
|
+
except RetryableError as exc:
|
|
132
|
+
last_exc = exc
|
|
133
|
+
if attempt < self.max_retries:
|
|
134
|
+
time.sleep(min(backoff, 30.0))
|
|
135
|
+
backoff *= 2
|
|
136
|
+
raise last_exc # type: ignore[misc]
|
|
137
|
+
|
|
138
|
+
def discover(
|
|
139
|
+
self,
|
|
140
|
+
start: date,
|
|
141
|
+
end: date,
|
|
142
|
+
) -> list[PublicationRef]:
|
|
143
|
+
"""List all HR publications in a date window.
|
|
144
|
+
|
|
145
|
+
Both ``PUBLISHED`` and ``CANCELLED`` states are queried. When the same
|
|
146
|
+
publication appears in both, ``CANCELLED`` wins.
|
|
147
|
+
"""
|
|
148
|
+
refs: list[PublicationRef] = []
|
|
149
|
+
for state in (PublicationState.PUBLISHED, PublicationState.CANCELLED):
|
|
150
|
+
refs.extend(self._discover_state(DateRange(start, end), state))
|
|
151
|
+
return _dedupe(refs)
|
|
152
|
+
|
|
153
|
+
def _discover_state(
|
|
154
|
+
self, date_range: DateRange, state: str
|
|
155
|
+
) -> list[PublicationRef]:
|
|
156
|
+
refs: list[PublicationRef] = []
|
|
157
|
+
page = 0
|
|
158
|
+
while True:
|
|
159
|
+
params = {
|
|
160
|
+
"tenant": "shab",
|
|
161
|
+
"rubrics": "HR",
|
|
162
|
+
"publicationStates": state,
|
|
163
|
+
"publicationDate.start": date_range.start.isoformat(),
|
|
164
|
+
"publicationDate.end": date_range.end.isoformat(),
|
|
165
|
+
"pageRequest.page": page,
|
|
166
|
+
"pageRequest.size": self.page_size,
|
|
167
|
+
}
|
|
168
|
+
response = self._get_with_retry(
|
|
169
|
+
f"{self.base_url}/publications/xml", params=params
|
|
170
|
+
)
|
|
171
|
+
page_refs, total = parse_bulk_export(response.content)
|
|
172
|
+
refs.extend(page_refs)
|
|
173
|
+
if not page_refs or len(refs) >= total:
|
|
174
|
+
break
|
|
175
|
+
page += 1
|
|
176
|
+
return refs
|
|
177
|
+
|
|
178
|
+
def fetch(self, ref: PublicationRef) -> RawResponse:
|
|
179
|
+
"""Fetch the full XML body of one publication."""
|
|
180
|
+
response = self._get_with_retry(ref.url)
|
|
181
|
+
return RawResponse(
|
|
182
|
+
ref=ref,
|
|
183
|
+
content=response.content,
|
|
184
|
+
content_type=response.headers.get("content-type", "application/xml"),
|
|
185
|
+
fetched_at=datetime.now(timezone.utc),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._client.close()
|
|
190
|
+
|
|
191
|
+
def __enter__(self):
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, *args):
|
|
195
|
+
self.close()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _dedupe(refs: list[PublicationRef]) -> list[PublicationRef]:
|
|
199
|
+
"""Collapse by external_id, preferring CANCELLED over PUBLISHED."""
|
|
200
|
+
seen: dict[str, PublicationRef] = {}
|
|
201
|
+
for ref in refs:
|
|
202
|
+
existing = seen.get(ref.external_id)
|
|
203
|
+
if existing is None or (
|
|
204
|
+
ref.publication_state == PublicationState.CANCELLED
|
|
205
|
+
and existing.publication_state != PublicationState.CANCELLED
|
|
206
|
+
):
|
|
207
|
+
seen[ref.external_id] = ref
|
|
208
|
+
result = list(seen.values())
|
|
209
|
+
result.sort(key=lambda r: (r.publication_date, r.external_id))
|
|
210
|
+
return result
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Parse SHAB publication XML into structured :class:`Publication` objects.
|
|
2
|
+
|
|
3
|
+
Event classification is driven by the machine-readable ``subRubric`` and
|
|
4
|
+
``<transaction>`` block (registration / changements flags / delete), so German,
|
|
5
|
+
French and Italian publications of the same act produce the same event types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import date
|
|
12
|
+
from xml.etree import ElementTree as ET
|
|
13
|
+
|
|
14
|
+
from .schemas import (
|
|
15
|
+
Company,
|
|
16
|
+
Event,
|
|
17
|
+
EventType,
|
|
18
|
+
ParserError,
|
|
19
|
+
Publication,
|
|
20
|
+
PublicationState,
|
|
21
|
+
RawResponse,
|
|
22
|
+
)
|
|
23
|
+
from ._xml_utils import child_text, is_true
|
|
24
|
+
|
|
25
|
+
PARSER_VERSION = "shab-parser/0.1.0"
|
|
26
|
+
|
|
27
|
+
LIQUIDATION_RE = re.compile(
|
|
28
|
+
r"\s*(?:in\s+Liquidation|en\s+liquidation|in\s+liquidazione)\s*",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
LIQUIDATION_DISSOLUTION_PATHS = (
|
|
33
|
+
"{*}statusChanged/{*}liquidation/{*}dissolution/{*}nonExceptional",
|
|
34
|
+
"{*}statusChanged/{*}liquidation/{*}dissolution/{*}or731b",
|
|
35
|
+
"{*}statusChanged/{*}liquidation/{*}dissolution/{*}hregv153b",
|
|
36
|
+
"{*}statusChanged/{*}bankruptcy/{*}dissolution",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
CAPITAL_INCREASE_PHRASES: dict[str, list[str]] = {
|
|
40
|
+
"de": [
|
|
41
|
+
"kapitalerhöhung",
|
|
42
|
+
"erhöhung des aktienkapitals",
|
|
43
|
+
"erhöhung des stammkapitals",
|
|
44
|
+
"erhöhung des kapitals",
|
|
45
|
+
],
|
|
46
|
+
"fr": ["augmentation du capital", "augmentation de capital"],
|
|
47
|
+
"it": ["aumento del capitale", "aumento di capitale"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
TAXONOMY_ORDER = [e.value for e in EventType]
|
|
51
|
+
|
|
52
|
+
_SUPPORTED_LANGUAGES = ("de", "fr", "it")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse(raw: RawResponse) -> Publication:
|
|
56
|
+
"""Parse one raw response into a :class:`Publication`.
|
|
57
|
+
|
|
58
|
+
Raises :class:`ParserError` for permanently unparseable input.
|
|
59
|
+
"""
|
|
60
|
+
return parse_xml(raw.content, source_url=raw.ref.url, ref_state=raw.ref.publication_state)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_xml(
|
|
64
|
+
xml_bytes: bytes,
|
|
65
|
+
*,
|
|
66
|
+
source_url: str = "",
|
|
67
|
+
ref_state: str | None = None,
|
|
68
|
+
) -> Publication:
|
|
69
|
+
"""Parse raw XML bytes into a :class:`Publication`.
|
|
70
|
+
|
|
71
|
+
This is the lower-level entry point when you already have the XML
|
|
72
|
+
and don't need the client's :class:`RawResponse` wrapper.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
root = ET.fromstring(xml_bytes)
|
|
76
|
+
except ET.ParseError as exc:
|
|
77
|
+
raise ParserError(f"invalid XML body: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
meta = root.find("{*}meta")
|
|
80
|
+
if meta is None:
|
|
81
|
+
raise ParserError("missing <meta> element")
|
|
82
|
+
|
|
83
|
+
external_id = child_text(meta, "{*}id")
|
|
84
|
+
if not external_id:
|
|
85
|
+
raise ParserError("missing meta/id")
|
|
86
|
+
|
|
87
|
+
language = (child_text(meta, "{*}language") or "").lower()
|
|
88
|
+
if language not in _SUPPORTED_LANGUAGES:
|
|
89
|
+
raise ParserError(f"unsupported language: {language!r}")
|
|
90
|
+
|
|
91
|
+
publication_date = _parse_date(child_text(meta, "{*}publicationDate"))
|
|
92
|
+
if publication_date is None:
|
|
93
|
+
raise ParserError("missing publicationDate")
|
|
94
|
+
|
|
95
|
+
sub_rubric = child_text(meta, "{*}subRubric") or ""
|
|
96
|
+
canton = child_text(meta, "{*}cantons")
|
|
97
|
+
publication_state = _resolve_publication_state(
|
|
98
|
+
child_text(meta, "{*}publicationState"), ref_state
|
|
99
|
+
)
|
|
100
|
+
content_el = root.find("{*}content")
|
|
101
|
+
|
|
102
|
+
company_el = None
|
|
103
|
+
if content_el is not None:
|
|
104
|
+
for path in ("{*}commonsNew/{*}company", "{*}commonsActual/{*}company"):
|
|
105
|
+
company_el = content_el.find(path)
|
|
106
|
+
if company_el is not None:
|
|
107
|
+
break
|
|
108
|
+
company_name = child_text(company_el, "{*}name")
|
|
109
|
+
uid = child_text(company_el, "{*}uid")
|
|
110
|
+
legal_form_code = child_text(company_el, "{*}legalForm")
|
|
111
|
+
|
|
112
|
+
prior_name = child_text(
|
|
113
|
+
content_el, "{*}commonsActual/{*}company/{*}name"
|
|
114
|
+
) if content_el is not None else ""
|
|
115
|
+
new_name = child_text(
|
|
116
|
+
content_el, "{*}commonsNew/{*}company/{*}name"
|
|
117
|
+
) if content_el is not None else ""
|
|
118
|
+
|
|
119
|
+
prior_seat = child_text(
|
|
120
|
+
content_el, "{*}commonsActual/{*}company/{*}seat"
|
|
121
|
+
) if content_el is not None else ""
|
|
122
|
+
new_seat = child_text(
|
|
123
|
+
content_el, "{*}commonsNew/{*}company/{*}seat"
|
|
124
|
+
) if content_el is not None else ""
|
|
125
|
+
|
|
126
|
+
title = child_text(meta, f"{{*}}title/{{*}}{language}")
|
|
127
|
+
if not company_name:
|
|
128
|
+
company_name = title
|
|
129
|
+
if not company_name:
|
|
130
|
+
raise ParserError("missing company name")
|
|
131
|
+
|
|
132
|
+
raw_text = child_text(content_el, "{*}publicationText") or title or ""
|
|
133
|
+
|
|
134
|
+
transaction = content_el.find("{*}transaction") if content_el is not None else None
|
|
135
|
+
journal_date = _parse_date(child_text(content_el, "{*}journalDate"))
|
|
136
|
+
deletion_date = _parse_date(child_text(transaction, "{*}delete/{*}deletionDate"))
|
|
137
|
+
|
|
138
|
+
effective_date = deletion_date if sub_rubric == "HR03" else journal_date
|
|
139
|
+
|
|
140
|
+
company_new = _extract_company(content_el, "{*}commonsNew/{*}company")
|
|
141
|
+
company_actual = _extract_company(content_el, "{*}commonsActual/{*}company")
|
|
142
|
+
capital_new = _to_float(child_text(content_el, "{*}commonsNew/{*}capital/{*}nominal"))
|
|
143
|
+
capital_actual = _to_float(child_text(content_el, "{*}commonsActual/{*}capital/{*}nominal"))
|
|
144
|
+
|
|
145
|
+
events = _classify(
|
|
146
|
+
sub_rubric, transaction, content_el, prior_name, new_name,
|
|
147
|
+
prior_seat, new_seat, raw_text, language, journal_date, deletion_date,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return Publication(
|
|
151
|
+
external_id=external_id,
|
|
152
|
+
publication_date=publication_date,
|
|
153
|
+
language=language,
|
|
154
|
+
source_url=source_url,
|
|
155
|
+
company_name=company_name,
|
|
156
|
+
raw_text=raw_text,
|
|
157
|
+
sub_rubric=sub_rubric,
|
|
158
|
+
effective_date=effective_date,
|
|
159
|
+
canton=canton or None,
|
|
160
|
+
uid=uid or None,
|
|
161
|
+
legal_form_code=legal_form_code or None,
|
|
162
|
+
publication_state=publication_state,
|
|
163
|
+
events=events,
|
|
164
|
+
company_new=company_new,
|
|
165
|
+
company_actual=company_actual,
|
|
166
|
+
capital_new=capital_new,
|
|
167
|
+
capital_actual=capital_actual,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_company(content_el: ET.Element | None, path: str) -> Company | None:
|
|
172
|
+
if content_el is None:
|
|
173
|
+
return None
|
|
174
|
+
el = content_el.find(path)
|
|
175
|
+
if el is None:
|
|
176
|
+
return None
|
|
177
|
+
name = child_text(el, "{*}name")
|
|
178
|
+
if not name:
|
|
179
|
+
return None
|
|
180
|
+
return Company(
|
|
181
|
+
name=name,
|
|
182
|
+
uid=child_text(el, "{*}uid"),
|
|
183
|
+
seat=child_text(el, "{*}seat"),
|
|
184
|
+
legal_form_code=child_text(el, "{*}legalForm"),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_publication_state(body_state: str | None, ref_state: str | None) -> str:
|
|
189
|
+
body = (body_state or "").upper()
|
|
190
|
+
ref = (ref_state or "").upper()
|
|
191
|
+
if body and ref and body != ref and PublicationState.CANCELLED in (body, ref):
|
|
192
|
+
return PublicationState.CANCELLED
|
|
193
|
+
return body or ref or PublicationState.PUBLISHED
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _renamed_beyond_liquidation(prior_name: str, new_name: str) -> bool:
|
|
197
|
+
prior = LIQUIDATION_RE.sub("", prior_name).strip()
|
|
198
|
+
new = LIQUIDATION_RE.sub("", new_name).strip()
|
|
199
|
+
return prior != new
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _classify(
|
|
203
|
+
sub_rubric: str,
|
|
204
|
+
transaction: ET.Element | None,
|
|
205
|
+
content_el: ET.Element | None,
|
|
206
|
+
prior_name: str,
|
|
207
|
+
new_name: str,
|
|
208
|
+
prior_seat: str,
|
|
209
|
+
new_seat: str,
|
|
210
|
+
text: str,
|
|
211
|
+
language: str,
|
|
212
|
+
journal_date: date | None,
|
|
213
|
+
deletion_date: date | None,
|
|
214
|
+
) -> list[Event]:
|
|
215
|
+
found: dict[str, Event] = {}
|
|
216
|
+
haystack = text.lower()
|
|
217
|
+
changements = transaction.find("{*}update/{*}changements") if transaction is not None else None
|
|
218
|
+
|
|
219
|
+
def add(event_type: EventType, effective_date: date | None, payload: dict) -> None:
|
|
220
|
+
found[event_type.value] = Event(
|
|
221
|
+
event_type=event_type, effective_date=effective_date, payload=payload
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if sub_rubric == "HR01":
|
|
225
|
+
if is_true(transaction, "{*}registration"):
|
|
226
|
+
payload: dict = {"sub_rubric": sub_rubric, "trigger": "registration"}
|
|
227
|
+
nominal = _to_float(child_text(content_el, "{*}commonsNew/{*}capital/{*}nominal"))
|
|
228
|
+
if nominal is not None:
|
|
229
|
+
payload["capital_nominal"] = nominal
|
|
230
|
+
add(EventType.INCORPORATION, journal_date, payload)
|
|
231
|
+
|
|
232
|
+
elif sub_rubric == "HR02":
|
|
233
|
+
if prior_seat and new_seat and prior_seat.strip() != new_seat.strip():
|
|
234
|
+
add(EventType.SEAT_MOVED, journal_date, {
|
|
235
|
+
"sub_rubric": sub_rubric,
|
|
236
|
+
"from": prior_seat.strip(),
|
|
237
|
+
"to": new_seat.strip(),
|
|
238
|
+
})
|
|
239
|
+
if is_true(changements, "{*}addressChanged"):
|
|
240
|
+
add(EventType.ADDRESS_CHANGED, journal_date, {"sub_rubric": sub_rubric})
|
|
241
|
+
if (
|
|
242
|
+
prior_name
|
|
243
|
+
and new_name
|
|
244
|
+
and prior_name.strip() != new_name.strip()
|
|
245
|
+
and _renamed_beyond_liquidation(prior_name, new_name)
|
|
246
|
+
):
|
|
247
|
+
add(EventType.NAME_CHANGED, journal_date, {
|
|
248
|
+
"sub_rubric": sub_rubric,
|
|
249
|
+
"from": prior_name.strip(),
|
|
250
|
+
"to": new_name.strip(),
|
|
251
|
+
})
|
|
252
|
+
capital = _capital_change(changements, content_el, haystack, language)
|
|
253
|
+
if capital is not None:
|
|
254
|
+
add(EventType.CAPITAL_INCREASED, journal_date, {"sub_rubric": sub_rubric, **capital})
|
|
255
|
+
|
|
256
|
+
elif sub_rubric == "HR03":
|
|
257
|
+
if transaction is not None and transaction.find("{*}delete") is not None:
|
|
258
|
+
add(EventType.DELETED, deletion_date or journal_date, {"sub_rubric": sub_rubric})
|
|
259
|
+
|
|
260
|
+
dissolved = any(
|
|
261
|
+
is_true(changements, path) for path in LIQUIDATION_DISSOLUTION_PATHS
|
|
262
|
+
)
|
|
263
|
+
entered = bool(
|
|
264
|
+
prior_name
|
|
265
|
+
and new_name
|
|
266
|
+
and not LIQUIDATION_RE.search(prior_name)
|
|
267
|
+
and LIQUIDATION_RE.search(new_name)
|
|
268
|
+
)
|
|
269
|
+
if dissolved or entered:
|
|
270
|
+
add(EventType.LIQUIDATION, journal_date, {
|
|
271
|
+
"sub_rubric": sub_rubric,
|
|
272
|
+
"trigger": "statusChanged" if dissolved else "name_entered_liquidation",
|
|
273
|
+
"from": (prior_name or "").strip(),
|
|
274
|
+
"to": (new_name or "").strip(),
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
return [found[et] for et in TAXONOMY_ORDER if et in found]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _capital_change(
|
|
281
|
+
changements: ET.Element | None,
|
|
282
|
+
content_el: ET.Element | None,
|
|
283
|
+
haystack: str,
|
|
284
|
+
language: str,
|
|
285
|
+
) -> dict | None:
|
|
286
|
+
capital = changements.find("{*}capitalChanged") if changements is not None else None
|
|
287
|
+
if capital is None:
|
|
288
|
+
return None
|
|
289
|
+
if not (is_true(capital, "{*}nominal") or is_true(capital, "{*}paid")):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
new_nominal = _to_float(child_text(content_el, "{*}commonsNew/{*}capital/{*}nominal"))
|
|
293
|
+
old_nominal = _to_float(child_text(content_el, "{*}commonsActual/{*}capital/{*}nominal"))
|
|
294
|
+
if new_nominal is not None and old_nominal is not None:
|
|
295
|
+
if new_nominal > old_nominal:
|
|
296
|
+
return {"from": old_nominal, "to": new_nominal, "direction": "structured"}
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
phrase = _capital_increase_phrase(language, haystack)
|
|
300
|
+
if phrase:
|
|
301
|
+
return {
|
|
302
|
+
"from": old_nominal,
|
|
303
|
+
"to": new_nominal,
|
|
304
|
+
"direction": "phrase",
|
|
305
|
+
"matched_phrase": phrase,
|
|
306
|
+
}
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _capital_increase_phrase(language: str, haystack: str) -> str | None:
|
|
311
|
+
for phrase in CAPITAL_INCREASE_PHRASES.get(language, []):
|
|
312
|
+
if phrase in haystack:
|
|
313
|
+
return phrase
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _to_float(value: str | None) -> float | None:
|
|
318
|
+
if not value:
|
|
319
|
+
return None
|
|
320
|
+
try:
|
|
321
|
+
return float(value.replace("'", "").replace(" ", ""))
|
|
322
|
+
except ValueError:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _parse_date(value: str | None) -> date | None:
|
|
327
|
+
if not value:
|
|
328
|
+
return None
|
|
329
|
+
try:
|
|
330
|
+
return date.fromisoformat(value)
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
raise ParserError(f"invalid date {value!r}: {exc}") from exc
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Data contracts for parsed SHAB publications.
|
|
2
|
+
|
|
3
|
+
Plain dataclasses with no external dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PublicationState(str, Enum):
|
|
14
|
+
PUBLISHED = "PUBLISHED"
|
|
15
|
+
CANCELLED = "CANCELLED"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SubRubric(str, Enum):
|
|
19
|
+
"""The three commercial-register sub-rubrics."""
|
|
20
|
+
|
|
21
|
+
HR01 = "HR01" # new registrations
|
|
22
|
+
HR02 = "HR02" # mutations
|
|
23
|
+
HR03 = "HR03" # deletions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EventType(str, Enum):
|
|
27
|
+
INCORPORATION = "INCORPORATION"
|
|
28
|
+
SEAT_MOVED = "SEAT_MOVED"
|
|
29
|
+
ADDRESS_CHANGED = "ADDRESS_CHANGED"
|
|
30
|
+
NAME_CHANGED = "NAME_CHANGED"
|
|
31
|
+
CAPITAL_INCREASED = "CAPITAL_INCREASED"
|
|
32
|
+
LIQUIDATION = "LIQUIDATION"
|
|
33
|
+
DELETED = "DELETED"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Language(str, Enum):
|
|
37
|
+
DE = "de"
|
|
38
|
+
FR = "fr"
|
|
39
|
+
IT = "it"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DateRange:
|
|
44
|
+
start: date
|
|
45
|
+
end: date
|
|
46
|
+
|
|
47
|
+
def contains(self, day: date) -> bool:
|
|
48
|
+
return self.start <= day <= self.end
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PublicationRef:
|
|
53
|
+
"""A discovered publication, before its body is fetched."""
|
|
54
|
+
|
|
55
|
+
external_id: str
|
|
56
|
+
publication_date: date
|
|
57
|
+
language: str
|
|
58
|
+
url: str
|
|
59
|
+
publication_state: str = PublicationState.PUBLISHED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class RawResponse:
|
|
64
|
+
"""The bytes returned for one publication."""
|
|
65
|
+
|
|
66
|
+
ref: PublicationRef
|
|
67
|
+
content: bytes
|
|
68
|
+
content_type: str
|
|
69
|
+
fetched_at: datetime
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Event:
|
|
74
|
+
"""One classified event from a publication."""
|
|
75
|
+
|
|
76
|
+
event_type: EventType
|
|
77
|
+
effective_date: date | None = None
|
|
78
|
+
payload: dict = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Company:
|
|
83
|
+
"""Structured company data from the XML."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
uid: str | None = None
|
|
87
|
+
seat: str | None = None
|
|
88
|
+
legal_form_code: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Publication:
|
|
93
|
+
"""A fully parsed SHAB publication."""
|
|
94
|
+
|
|
95
|
+
external_id: str
|
|
96
|
+
publication_date: date
|
|
97
|
+
language: str
|
|
98
|
+
source_url: str
|
|
99
|
+
company_name: str
|
|
100
|
+
raw_text: str
|
|
101
|
+
sub_rubric: str
|
|
102
|
+
effective_date: date | None = None
|
|
103
|
+
canton: str | None = None
|
|
104
|
+
uid: str | None = None
|
|
105
|
+
legal_form_code: str | None = None
|
|
106
|
+
publication_state: str = PublicationState.PUBLISHED
|
|
107
|
+
events: list[Event] = field(default_factory=list)
|
|
108
|
+
company_new: Company | None = None
|
|
109
|
+
company_actual: Company | None = None
|
|
110
|
+
capital_new: float | None = None
|
|
111
|
+
capital_actual: float | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ShabError(Exception):
|
|
115
|
+
"""Base exception for shab-parser."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ParserError(ShabError):
|
|
119
|
+
"""Input that cannot be parsed."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TransportError(ShabError):
|
|
123
|
+
"""A request to the SHAB API failed."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RetryableError(TransportError):
|
|
127
|
+
"""A transient failure (rate limit, server error) worth retrying."""
|