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.
@@ -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
+ [![Documentation](https://readthedocs.org/projects/shab-parser/badge/?version=latest)](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
+ [![Documentation](https://readthedocs.org/projects/shab-parser/badge/?version=latest)](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."""