firmendata 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,91 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.11", "3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - run: pip install -e '.[dev]'
25
+ - run: pytest -q
26
+ - run: ruff check .
27
+ - run: mypy
28
+ # One version is enough for type checking; the others only run tests.
29
+ if: matrix.python-version == '3.11'
30
+
31
+ # The SDK vendors contracts/openapi.v1.json and generates its types from it.
32
+ # If the committed types don't match what the vendored spec produces, the
33
+ # SDK is lying about the API — fail rather than ship that.
34
+ generated-types-are-current:
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: actions/setup-python@v5
39
+ with:
40
+ python-version: "3.11"
41
+ - run: pip install -e '.[dev]'
42
+ - run: python scripts/generate_types.py
43
+ - name: Types match the vendored contract
44
+ run: |
45
+ if ! git diff --exit-code -- src/firmendata/types.py src/firmendata/params.py; then
46
+ echo "::error::Generated types are stale. Run 'python scripts/generate_types.py' and commit."
47
+ exit 1
48
+ fi
49
+
50
+ # Advisory: tells us when the live API has moved past our vendored copy.
51
+ # Never blocks — the SDK targets the spec it ships with, and a drifting
52
+ # upstream is a signal to cut a release, not a reason to fail this build.
53
+ upstream-drift:
54
+ runs-on: ubuntu-latest
55
+ if: github.event_name == 'push'
56
+ continue-on-error: true
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+ - name: Compare vendored spec against production
60
+ run: |
61
+ curl -sSL https://api.firmendata.com/v1/openapi.json -o /tmp/live.json
62
+ python - <<'PY'
63
+ import json
64
+ live = json.load(open('/tmp/live.json'))
65
+ vendored = json.load(open('contracts/openapi.v1.json'))
66
+ lp, vp = set(live['paths']), set(vendored['paths'])
67
+ if lp != vp:
68
+ print(f"::warning::Vendored spec is out of date. "
69
+ f"added={sorted(lp - vp)} removed={sorted(vp - lp)}")
70
+ else:
71
+ print("Vendored spec matches production paths.")
72
+ PY
73
+
74
+ secrets:
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ with:
79
+ # Full history so a secret committed and later removed is still found.
80
+ fetch-depth: 0
81
+ # The gitleaks CLI, not gitleaks/gitleaks-action. The scanner is MIT and
82
+ # free; it is the Action wrapper that requires a paid licence for
83
+ # organisation-owned repos, and it fails the job outright without one.
84
+ # Running the binary directly is the same scan with no licence.
85
+ - name: Scan git history for secrets
86
+ env:
87
+ GITLEAKS_VERSION: 8.30.1
88
+ run: |
89
+ curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
90
+ | tar xz -C /tmp gitleaks
91
+ /tmp/gitleaks git . --no-banner --redact --exit-code 1
@@ -0,0 +1,99 @@
1
+ # Publishes to PyPI on a version tag.
2
+ #
3
+ # ─── AUTHENTICATION ────────────────────────────────────────────────────────
4
+ #
5
+ # Trusted Publishing (OIDC). There is no PyPI token anywhere — GitHub mints a
6
+ # short-lived credential per run and PyPI verifies it came from this exact
7
+ # repository and workflow. Nothing to rotate, nothing to leak.
8
+ #
9
+ # PyPI supports "pending publishers", so this works for the very first
10
+ # release of a project that does not exist yet. (npm does not, which is why
11
+ # the JavaScript packages take a different route on their first publish.)
12
+ #
13
+ # ─── ONE-TIME SETUP ────────────────────────────────────────────────────────
14
+ #
15
+ # On https://pypi.org/manage/account/publishing/ add a pending publisher:
16
+ #
17
+ # PyPI project name: firmendata
18
+ # Owner: FirmenData
19
+ # Repository name: firmendata-python
20
+ # Workflow name: publish.yml
21
+ # Environment name: pypi
22
+ #
23
+ # Then create the `pypi` environment under repo Settings → Environments. The
24
+ # environment is not decoration: it is where you add a required reviewer, so
25
+ # a tag push cannot publish to PyPI without a human approving the run.
26
+ #
27
+ # ─── RELEASING ─────────────────────────────────────────────────────────────
28
+ #
29
+ # 1. bump __version__ in src/firmendata/_version.py
30
+ # 2. commit, then: git tag 0.1.0 && git push origin 0.1.0
31
+ #
32
+ # A version can never be re-uploaded to PyPI, even after deletion. The build
33
+ # is verified here before the publish step runs, and the publish step is a
34
+ # separate job so the artifacts are inspectable if it fails.
35
+
36
+ name: Publish
37
+
38
+ on:
39
+ push:
40
+ tags: ['*.*.*']
41
+ workflow_dispatch:
42
+
43
+ permissions:
44
+ contents: read
45
+
46
+ jobs:
47
+ build:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ - uses: actions/setup-python@v5
52
+ with:
53
+ python-version: '3.11'
54
+
55
+ - run: pip install -e '.[dev]' build twine
56
+
57
+ # A tag is not a licence to skip the tests.
58
+ - run: pytest -q
59
+ - run: mypy
60
+ - run: ruff check .
61
+
62
+ # The generated types must match the vendored contract, or we would
63
+ # ship an SDK that lies about the API.
64
+ - run: python scripts/generate_types.py
65
+ - run: git diff --exit-code -- src/firmendata/
66
+
67
+ - name: Tag must match the package version
68
+ run: |
69
+ TAG="${GITHUB_REF_NAME}"
70
+ PKG=$(python -c "import re,pathlib; \
71
+ print(re.search(r'\"([^\"]+)\"', pathlib.Path('src/firmendata/_version.py').read_text()).group(1))")
72
+ if [ "$TAG" != "$PKG" ]; then
73
+ echo "::error::Tag $TAG does not match __version__ $PKG"
74
+ exit 1
75
+ fi
76
+ if: github.event_name == 'push'
77
+
78
+ - run: python -m build
79
+ - run: twine check dist/*
80
+
81
+ - uses: actions/upload-artifact@v4
82
+ with:
83
+ name: dist
84
+ path: dist/
85
+
86
+ publish:
87
+ needs: build
88
+ runs-on: ubuntu-latest
89
+ environment: pypi
90
+ permissions:
91
+ # Required to mint the OIDC token PyPI verifies. Nothing else is needed —
92
+ # in particular there is no PYPI_TOKEN secret.
93
+ id-token: write
94
+ steps:
95
+ - uses: actions/download-artifact@v4
96
+ with:
97
+ name: dist
98
+ path: dist/
99
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .mypy_cache/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FirmenData
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.5
2
+ Name: firmendata
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the firmendata German company-data API
5
+ Project-URL: Homepage, https://firmendata.com
6
+ Project-URL: Documentation, https://api.firmendata.com/v1/docs
7
+ Project-URL: Source, https://github.com/FirmenData/firmendata-python
8
+ Project-URL: Issues, https://github.com/FirmenData/firmendata-python/issues
9
+ Author-email: FirmenData <info@firmendata.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-client,company-data,company-register,firmendata,germany,handelsregister,kyc,ubo,unternehmensregister
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Office/Business :: Financial
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: datamodel-code-generator>=0.25; extra == 'dev'
27
+ Requires-Dist: mypy>=1.11; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: respx>=0.21; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # firmendata-python
35
+
36
+ Official Python client for the [firmendata](https://firmendata.com) API — data on
37
+ **2.4 million German companies** from the Unternehmensregister and Handelsregister:
38
+ register profiles, parsed annual financial statements, shareholder cap tables,
39
+ UBO chains, insolvency notices and public-tender links.
40
+
41
+ [![PyPI](https://img.shields.io/pypi/v/firmendata)](https://pypi.org/project/firmendata/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/firmendata)](https://pypi.org/project/firmendata/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
44
+
45
+ ```bash
46
+ pip install firmendata
47
+ ```
48
+
49
+ ## Try it without signing up
50
+
51
+ Company-name autocomplete is free and needs **no API key**:
52
+
53
+ ```python
54
+ from firmendata import FirmenData
55
+
56
+ for hit in FirmenData().autocomplete("siemens")["data"]:
57
+ print(hit["eu_id"], hit["display_name"])
58
+ ```
59
+
60
+ Keyless calls are rate limited, modestly and by address — enough to try the
61
+ API, back a search box, or run low-volume queries. Add a key for substantially
62
+ higher limits plus every other endpoint. On a `429`, honour `Retry-After`;
63
+ the client already does this for you.
64
+
65
+ ## With an API key
66
+
67
+ Create one at [firmendata.com](https://firmendata.com/de/account/api-keys) — the
68
+ free plan includes 100 credits.
69
+
70
+ ```python
71
+ from firmendata import FirmenData
72
+
73
+ fd = FirmenData(api_key="firmendata_live_...")
74
+
75
+ # Advanced search — filters combine with AND, lists with OR
76
+ results = fd.search(
77
+ city=["Berlin", "Hamburg"],
78
+ revenue_min=1_000_000,
79
+ legal_status=["insolvent"],
80
+ limit=25,
81
+ )
82
+
83
+ for hit in results["data"]:
84
+ print(hit["display_name"], hit["address"]["city"])
85
+
86
+ # Paginate
87
+ if results["pagination"]["has_more"]:
88
+ next_page = fd.search(cursor=results["pagination"]["next_cursor"])
89
+ ```
90
+
91
+ ```python
92
+ eu_id = "DEB1103R_HRB123456"
93
+
94
+ fd.get_company(eu_id) # full profile
95
+ fd.get_financials(eu_id) # multi-year statements, parsed into figures
96
+ fd.get_shareholders(eu_id) # cap table from the Gesellschafterliste
97
+ fd.get_ubo(eu_id) # beneficial owners through ownership chains
98
+ fd.get_history(eu_id) # chronological register history
99
+ ```
100
+
101
+ ### Async
102
+
103
+ Same methods, same semantics:
104
+
105
+ ```python
106
+ import asyncio
107
+ from firmendata import AsyncFirmenData
108
+
109
+ async def main():
110
+ async with AsyncFirmenData(api_key="firmendata_live_...") as fd:
111
+ company = await fd.get_company("DEB1103R_HRB123456")
112
+ print(company["display_name"])
113
+
114
+ asyncio.run(main())
115
+ ```
116
+
117
+ ## Errors
118
+
119
+ Every failure is a typed exception carrying the API's RFC 7807 problem detail,
120
+ including a `request_id` you can quote to support.
121
+
122
+ ```python
123
+ from firmendata import FirmenData, InsufficientCreditsError, RateLimitError
124
+
125
+ try:
126
+ fd.get_ubo(eu_id)
127
+ except InsufficientCreditsError:
128
+ ... # top up or upgrade
129
+ except RateLimitError as e:
130
+ ... # e.retry_after is the server's own hint
131
+ ```
132
+
133
+ | Exception | Status | Meaning |
134
+ |---|---|---|
135
+ | `AuthenticationError` | 401 | Missing/invalid key, or a keyless call used a paid feature |
136
+ | `TokenExpiredError` | 401 | Key expired |
137
+ | `InsufficientCreditsError` | 402 | Balance too low for this call |
138
+ | `NotFoundError` | 404 | No such company, subscription or event |
139
+ | `ConflictError` | 409 | Conflicts with existing state |
140
+ | `ValidationError` | 422 | Bad parameters — see `.errors` for the fields |
141
+ | `RateLimitError` | 429 | Retry budget exhausted — see `.retry_after` |
142
+ | `ServerError` | 5xx | Retried automatically for idempotent calls |
143
+ | `APIConnectionError` / `APITimeoutError` | — | No response at all |
144
+
145
+ ### Retries
146
+
147
+ Automatic and deliberately conservative:
148
+
149
+ - **429 is always retried**, on any method — the server rejects rate-limited
150
+ calls before the handler runs, so nothing happened and nothing was billed.
151
+ The server's `Retry-After` is used verbatim.
152
+ - **5xx and connection failures are retried only for idempotent methods.** A
153
+ `create_subscription` that times out may already have been applied; replaying
154
+ it would create a second one.
155
+ - Backoff is exponential with full jitter, so clients that trip the same limit
156
+ together don't all return at the same instant.
157
+
158
+ Tune with `FirmenData(max_retries=...)`; `0` disables it.
159
+
160
+ ## Types
161
+
162
+ Responses are plain dictionaries described by generated `TypedDict`s, so editors
163
+ complete every field and `mypy` checks them — with **no pydantic dependency** to
164
+ collide with your own. The only runtime requirement is `httpx`.
165
+
166
+ Both `src/firmendata/types.py` and `src/firmendata/params.py` are generated from
167
+ [`contracts/openapi.v1.json`](contracts/openapi.v1.json), a vendored copy of the
168
+ published spec:
169
+
170
+ ```bash
171
+ python scripts/generate_types.py
172
+ ```
173
+
174
+ CI regenerates them and fails if the result differs from what is committed, so
175
+ the SDK cannot silently drift from the API it targets.
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ pip install -e '.[dev]'
181
+ pytest # no network, no credentials
182
+ mypy && ruff check
183
+ ```
184
+
185
+ ## Links
186
+
187
+ - API reference — <https://api.firmendata.com/v1/docs>
188
+ - TypeScript SDK — <https://github.com/FirmenData/firmendata-node>
189
+ - n8n node — <https://github.com/FirmenData/n8n-nodes-firmendata>
190
+ - MCP server (for AI agents) — `https://mcp.firmendata.com/mcp`
191
+ - Website — <https://firmendata.com>
192
+
193
+ ## License
194
+
195
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,162 @@
1
+ # firmendata-python
2
+
3
+ Official Python client for the [firmendata](https://firmendata.com) API — data on
4
+ **2.4 million German companies** from the Unternehmensregister and Handelsregister:
5
+ register profiles, parsed annual financial statements, shareholder cap tables,
6
+ UBO chains, insolvency notices and public-tender links.
7
+
8
+ [![PyPI](https://img.shields.io/pypi/v/firmendata)](https://pypi.org/project/firmendata/)
9
+ [![Python](https://img.shields.io/pypi/pyversions/firmendata)](https://pypi.org/project/firmendata/)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
11
+
12
+ ```bash
13
+ pip install firmendata
14
+ ```
15
+
16
+ ## Try it without signing up
17
+
18
+ Company-name autocomplete is free and needs **no API key**:
19
+
20
+ ```python
21
+ from firmendata import FirmenData
22
+
23
+ for hit in FirmenData().autocomplete("siemens")["data"]:
24
+ print(hit["eu_id"], hit["display_name"])
25
+ ```
26
+
27
+ Keyless calls are rate limited, modestly and by address — enough to try the
28
+ API, back a search box, or run low-volume queries. Add a key for substantially
29
+ higher limits plus every other endpoint. On a `429`, honour `Retry-After`;
30
+ the client already does this for you.
31
+
32
+ ## With an API key
33
+
34
+ Create one at [firmendata.com](https://firmendata.com/de/account/api-keys) — the
35
+ free plan includes 100 credits.
36
+
37
+ ```python
38
+ from firmendata import FirmenData
39
+
40
+ fd = FirmenData(api_key="firmendata_live_...")
41
+
42
+ # Advanced search — filters combine with AND, lists with OR
43
+ results = fd.search(
44
+ city=["Berlin", "Hamburg"],
45
+ revenue_min=1_000_000,
46
+ legal_status=["insolvent"],
47
+ limit=25,
48
+ )
49
+
50
+ for hit in results["data"]:
51
+ print(hit["display_name"], hit["address"]["city"])
52
+
53
+ # Paginate
54
+ if results["pagination"]["has_more"]:
55
+ next_page = fd.search(cursor=results["pagination"]["next_cursor"])
56
+ ```
57
+
58
+ ```python
59
+ eu_id = "DEB1103R_HRB123456"
60
+
61
+ fd.get_company(eu_id) # full profile
62
+ fd.get_financials(eu_id) # multi-year statements, parsed into figures
63
+ fd.get_shareholders(eu_id) # cap table from the Gesellschafterliste
64
+ fd.get_ubo(eu_id) # beneficial owners through ownership chains
65
+ fd.get_history(eu_id) # chronological register history
66
+ ```
67
+
68
+ ### Async
69
+
70
+ Same methods, same semantics:
71
+
72
+ ```python
73
+ import asyncio
74
+ from firmendata import AsyncFirmenData
75
+
76
+ async def main():
77
+ async with AsyncFirmenData(api_key="firmendata_live_...") as fd:
78
+ company = await fd.get_company("DEB1103R_HRB123456")
79
+ print(company["display_name"])
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ ## Errors
85
+
86
+ Every failure is a typed exception carrying the API's RFC 7807 problem detail,
87
+ including a `request_id` you can quote to support.
88
+
89
+ ```python
90
+ from firmendata import FirmenData, InsufficientCreditsError, RateLimitError
91
+
92
+ try:
93
+ fd.get_ubo(eu_id)
94
+ except InsufficientCreditsError:
95
+ ... # top up or upgrade
96
+ except RateLimitError as e:
97
+ ... # e.retry_after is the server's own hint
98
+ ```
99
+
100
+ | Exception | Status | Meaning |
101
+ |---|---|---|
102
+ | `AuthenticationError` | 401 | Missing/invalid key, or a keyless call used a paid feature |
103
+ | `TokenExpiredError` | 401 | Key expired |
104
+ | `InsufficientCreditsError` | 402 | Balance too low for this call |
105
+ | `NotFoundError` | 404 | No such company, subscription or event |
106
+ | `ConflictError` | 409 | Conflicts with existing state |
107
+ | `ValidationError` | 422 | Bad parameters — see `.errors` for the fields |
108
+ | `RateLimitError` | 429 | Retry budget exhausted — see `.retry_after` |
109
+ | `ServerError` | 5xx | Retried automatically for idempotent calls |
110
+ | `APIConnectionError` / `APITimeoutError` | — | No response at all |
111
+
112
+ ### Retries
113
+
114
+ Automatic and deliberately conservative:
115
+
116
+ - **429 is always retried**, on any method — the server rejects rate-limited
117
+ calls before the handler runs, so nothing happened and nothing was billed.
118
+ The server's `Retry-After` is used verbatim.
119
+ - **5xx and connection failures are retried only for idempotent methods.** A
120
+ `create_subscription` that times out may already have been applied; replaying
121
+ it would create a second one.
122
+ - Backoff is exponential with full jitter, so clients that trip the same limit
123
+ together don't all return at the same instant.
124
+
125
+ Tune with `FirmenData(max_retries=...)`; `0` disables it.
126
+
127
+ ## Types
128
+
129
+ Responses are plain dictionaries described by generated `TypedDict`s, so editors
130
+ complete every field and `mypy` checks them — with **no pydantic dependency** to
131
+ collide with your own. The only runtime requirement is `httpx`.
132
+
133
+ Both `src/firmendata/types.py` and `src/firmendata/params.py` are generated from
134
+ [`contracts/openapi.v1.json`](contracts/openapi.v1.json), a vendored copy of the
135
+ published spec:
136
+
137
+ ```bash
138
+ python scripts/generate_types.py
139
+ ```
140
+
141
+ CI regenerates them and fails if the result differs from what is committed, so
142
+ the SDK cannot silently drift from the API it targets.
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ pip install -e '.[dev]'
148
+ pytest # no network, no credentials
149
+ mypy && ruff check
150
+ ```
151
+
152
+ ## Links
153
+
154
+ - API reference — <https://api.firmendata.com/v1/docs>
155
+ - TypeScript SDK — <https://github.com/FirmenData/firmendata-node>
156
+ - n8n node — <https://github.com/FirmenData/n8n-nodes-firmendata>
157
+ - MCP server (for AI agents) — `https://mcp.firmendata.com/mcp`
158
+ - Website — <https://firmendata.com>
159
+
160
+ ## License
161
+
162
+ MIT — see [LICENSE](LICENSE).