Skip to content

Commit a87599a

Browse files
authored
Merge pull request #11 from smekcio/feat/ksef-2-1-2-docs-alignment-python
feat: align python sdk with ksef docs 2.1.2
2 parents 4eaf3e1 + 843c8a5 commit a87599a

7 files changed

Lines changed: 191 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ SDK zostało zaprojektowane w oparciu o oficjalne biblioteki referencyjne KSeF d
1212

1313
## 🔄 Kompatybilność API KSeF
1414

15-
Aktualna kompatybilność: **KSeF API `v2.1.1`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.1/api-changelog.md)).
15+
Aktualna kompatybilność: **KSeF API `v2.1.2`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.2/api-changelog.md)).
1616

1717
## ✅ Funkcjonalności
1818

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Dokumentacja opisuje **publiczne API** biblioteki `ksef-client-python` (import:
44

55
Opis kontraktu API (OpenAPI) oraz dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`.
66

7-
Kompatybilność SDK: **KSeF API `v2.1.1`**.
7+
Kompatybilność SDK: **KSeF API `v2.1.2`**.
88

99
## Wymagania
1010

docs/api/invoices.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Endpoint służy do wyszukiwania metadanych faktur. `request_payload` zależy od
2222

2323
Typowe zastosowanie: synchronizacja historii metadanych i późniejsze pobieranie treści XML po `ksefNumber`.
2424

25+
Uwaga dla `dateRange`:
26+
- jeśli `dateRange.from` / `dateRange.to` jest podane jako ISO date-time bez offsetu (`YYYY-MM-DDTHH:MM[:SS]`),
27+
SDK normalizuje je do strefy `Europe/Warsaw` i wysyła z jawnie dopisanym offsetem (`+01:00`/`+02:00`).
28+
2529
## `export_invoices(request_payload, access_token)`
2630

2731
Endpoint: `POST /invoices/exports`
@@ -33,6 +37,9 @@ Wymagane minimum w `request_payload`:
3337
- `encryption.initializationVector`
3438
- `filters` (np. `subjectType` + `dateRange`)
3539

40+
Uwaga dla `filters.dateRange`:
41+
- ISO date-time bez offsetu jest normalizowany do `Europe/Warsaw` przed wysyłką requestu.
42+
3643
## `get_export_status(reference_number, access_token)`
3744

3845
Endpoint: `GET /invoices/exports/{referenceNumber}`

src/ksef_client/clients/invoices.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,66 @@
11
from __future__ import annotations
22

3+
import re
4+
from copy import deepcopy
5+
from datetime import date, datetime, timedelta, timezone
36
from typing import Any
47

58
from ..models import BinaryContent, InvoiceContent
69
from .base import AsyncBaseApiClient, BaseApiClient
710

11+
_OFFSET_SUFFIX_RE = re.compile(r"(?:Z|[+-]\d{2}:?\d{2})$")
12+
13+
14+
def _last_sunday_of_month(year: int, month: int) -> int:
15+
cursor = date(year, 12, 31) if month == 12 else date(year, month + 1, 1) - timedelta(days=1)
16+
while cursor.weekday() != 6: # Sunday
17+
cursor -= timedelta(days=1)
18+
return cursor.day
19+
20+
21+
def _warsaw_offset_for_local_datetime(local_dt: datetime) -> timedelta:
22+
year = local_dt.year
23+
dst_start = datetime(year, 3, _last_sunday_of_month(year, 3), 2, 0, 0)
24+
dst_end = datetime(year, 10, _last_sunday_of_month(year, 10), 3, 0, 0)
25+
if dst_start <= local_dt < dst_end:
26+
return timedelta(hours=2)
27+
return timedelta(hours=1)
28+
29+
30+
def _normalize_datetime_without_offset(value: str) -> str:
31+
if "T" not in value or _OFFSET_SUFFIX_RE.search(value):
32+
return value
33+
try:
34+
parsed = datetime.fromisoformat(value)
35+
except ValueError:
36+
return value
37+
if parsed.tzinfo is not None:
38+
return value
39+
offset = _warsaw_offset_for_local_datetime(parsed)
40+
return parsed.replace(tzinfo=timezone(offset)).isoformat()
41+
42+
43+
def _normalize_invoice_date_range_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
44+
normalized = deepcopy(request_payload)
45+
date_range_candidates: list[dict[str, Any]] = []
46+
47+
top_level = normalized.get("dateRange")
48+
if isinstance(top_level, dict):
49+
date_range_candidates.append(top_level)
50+
51+
filters = normalized.get("filters")
52+
if isinstance(filters, dict):
53+
nested = filters.get("dateRange")
54+
if isinstance(nested, dict):
55+
date_range_candidates.append(nested)
56+
57+
for date_range in date_range_candidates:
58+
for field_name in ("from", "to"):
59+
value = date_range.get(field_name)
60+
if isinstance(value, str):
61+
date_range[field_name] = _normalize_datetime_without_offset(value)
62+
return normalized
63+
864

965
class InvoicesClient(BaseApiClient):
1066
def get_invoice(self, ksef_number: str, *, access_token: str) -> InvoiceContent:
@@ -39,6 +95,7 @@ def query_invoice_metadata(
3995
page_size: int | None = None,
4096
sort_order: str | None = None,
4197
) -> Any:
98+
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
4299
params: dict[str, Any] = {}
43100
if page_offset is not None:
44101
params["pageOffset"] = page_offset
@@ -50,15 +107,16 @@ def query_invoice_metadata(
50107
"POST",
51108
"/invoices/query/metadata",
52109
params=params or None,
53-
json=request_payload,
110+
json=normalized_payload,
54111
access_token=access_token,
55112
)
56113

57114
def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any:
115+
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
58116
return self._request_json(
59117
"POST",
60118
"/invoices/exports",
61-
json=request_payload,
119+
json=normalized_payload,
62120
access_token=access_token,
63121
expected_status={201, 202},
64122
)
@@ -119,6 +177,7 @@ async def query_invoice_metadata(
119177
page_size: int | None = None,
120178
sort_order: str | None = None,
121179
) -> Any:
180+
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
122181
params: dict[str, Any] = {}
123182
if page_offset is not None:
124183
params["pageOffset"] = page_offset
@@ -130,15 +189,16 @@ async def query_invoice_metadata(
130189
"POST",
131190
"/invoices/query/metadata",
132191
params=params or None,
133-
json=request_payload,
192+
json=normalized_payload,
134193
access_token=access_token,
135194
)
136195

137196
async def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any:
197+
normalized_payload = _normalize_invoice_date_range_payload(request_payload)
138198
return await self._request_json(
139199
"POST",
140200
"/invoices/exports",
141-
json=request_payload,
201+
json=normalized_payload,
142202
access_token=access_token,
143203
expected_status={201, 202},
144204
)

src/ksef_client/openapi_models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ class TokenPermissionType(Enum):
671671
CREDENTIALSMANAGE = "CredentialsManage"
672672
SUBUNITMANAGE = "SubunitManage"
673673
ENFORCEMENTOPERATIONS = "EnforcementOperations"
674+
INTROSPECTION = "Introspection"
674675

675676
Challenge: TypeAlias = str
676677

@@ -1291,7 +1292,7 @@ class InvoiceStatusInfo(OpenApiModel):
12911292
code: int
12921293
description: str
12931294
details: Optional[list[str]] = None
1294-
extensions: Optional[dict[str, Optional[str]]] = None
1295+
extensions: Optional[dict[str, Any]] = None
12951296

12961297
@dataclass(frozen=True)
12971298
class OnlineSessionContextLimitsOverride(OpenApiModel):
@@ -1329,7 +1330,7 @@ class OpenOnlineSessionResponse(OpenApiModel):
13291330

13301331
@dataclass(frozen=True)
13311332
class PartUploadRequest(OpenApiModel):
1332-
headers: dict[str, Optional[str]]
1333+
headers: dict[str, Any]
13331334
method: str
13341335
ordinalNumber: int
13351336
url: str

tests/test_clients.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,28 +110,65 @@ def test_sessions_client(self):
110110

111111
def test_invoices_client(self):
112112
client = InvoicesClient(self.http)
113+
query_payload = {
114+
"subjectType": "Subject1",
115+
"dateRange": {
116+
"dateType": "Issue",
117+
"from": "2025-01-02T10:15:00",
118+
"to": "2025-01-02T11:15:00",
119+
},
120+
}
121+
export_payload = {
122+
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
123+
"filters": {
124+
"subjectType": "Subject1",
125+
"dateRange": {
126+
"dateType": "Issue",
127+
"from": "2025-07-02T10:15:00",
128+
"to": "2025-07-02T11:15:00",
129+
},
130+
},
131+
}
113132
with (
114133
patch.object(client, "_request_raw", Mock(return_value=self.response)),
115-
patch.object(client, "_request_json", Mock(return_value={"ok": True})),
134+
patch.object(
135+
client, "_request_json", Mock(return_value={"ok": True})
136+
) as request_json_mock,
116137
patch.object(client, "_request_bytes", Mock(return_value=b"bytes")),
117138
):
118139
invoice = client.get_invoice("ksef", access_token="token")
119140
self.assertEqual(invoice.sha256_base64, "hash")
120141
invoice_bytes = client.get_invoice_bytes("ksef", access_token="token")
121142
self.assertEqual(invoice_bytes.sha256_base64, "hash")
122143
client.query_invoice_metadata(
123-
{"a": 1},
144+
query_payload,
124145
access_token="token",
125146
page_offset=0,
126147
page_size=10,
127148
sort_order="asc",
128149
)
129-
client.export_invoices({"a": 1}, access_token="token")
150+
client.export_invoices(export_payload, access_token="token")
130151
client.get_export_status("ref", access_token="token")
131152
client.download_export_part("https://example.com")
132153
client.download_package_part("https://example.com")
133154
with_hash = client.download_export_part_with_hash("https://example.com")
134155
self.assertEqual(with_hash.sha256_base64, "hash")
156+
self.assertEqual(
157+
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"],
158+
"2025-01-02T10:15:00+01:00",
159+
)
160+
self.assertEqual(
161+
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["to"],
162+
"2025-01-02T11:15:00+01:00",
163+
)
164+
self.assertEqual(
165+
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"],
166+
"2025-07-02T10:15:00+02:00",
167+
)
168+
self.assertEqual(
169+
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["to"],
170+
"2025-07-02T11:15:00+02:00",
171+
)
135172

136173
def test_permissions_client(self):
137174
client = PermissionsClient(self.http)
@@ -333,25 +370,54 @@ async def test_async_clients(self):
333370
await sessions.get_session_upo("ref", "upo", access_token="token")
334371

335372
invoices = AsyncInvoicesClient(http)
373+
query_payload = {
374+
"subjectType": "Subject1",
375+
"dateRange": {
376+
"dateType": "Issue",
377+
"from": "2025-01-02T10:15:00",
378+
"to": "2025-01-02T11:15:00",
379+
},
380+
}
381+
export_payload = {
382+
"encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"},
383+
"filters": {
384+
"subjectType": "Subject1",
385+
"dateRange": {
386+
"dateType": "Issue",
387+
"from": "2025-07-02T10:15:00",
388+
"to": "2025-07-02T11:15:00",
389+
},
390+
},
391+
}
336392
with (
337393
patch.object(invoices, "_request_raw", AsyncMock(return_value=response)),
338-
patch.object(invoices, "_request_json", AsyncMock(return_value={"ok": True})),
394+
patch.object(
395+
invoices, "_request_json", AsyncMock(return_value={"ok": True})
396+
) as request_json_mock,
339397
patch.object(invoices, "_request_bytes", AsyncMock(return_value=b"bytes")),
340398
):
341399
await invoices.get_invoice("ksef", access_token="token")
342400
await invoices.get_invoice_bytes("ksef", access_token="token")
343401
await invoices.query_invoice_metadata(
344-
{"a": 1},
402+
query_payload,
345403
access_token="token",
346404
page_offset=0,
347405
page_size=10,
348406
sort_order="asc",
349407
)
350-
await invoices.export_invoices({"a": 1}, access_token="token")
408+
await invoices.export_invoices(export_payload, access_token="token")
351409
await invoices.get_export_status("ref", access_token="token")
352410
await invoices.download_export_part("https://example.com")
353411
await invoices.download_package_part("https://example.com")
354412
await invoices.download_export_part_with_hash("https://example.com")
413+
self.assertEqual(
414+
request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"],
415+
"2025-01-02T10:15:00+01:00",
416+
)
417+
self.assertEqual(
418+
request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"],
419+
"2025-07-02T10:15:00+02:00",
420+
)
355421

356422
permissions = AsyncPermissionsClient(http)
357423
with patch.object(permissions, "_request_json", AsyncMock(return_value={"ok": True})):

tests/test_openapi_models.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import unittest
3+
from pathlib import Path
24

35
from ksef_client import openapi_models as m
46

@@ -55,14 +57,18 @@ def test_invoice_status_extensions(self):
5557
payload = {
5658
"code": 200,
5759
"description": "ok",
58-
"extensions": {"x": None, "y": "1"},
60+
"extensions": {"x": None, "y": "1", "nested": {"a": 1}, "flag": True},
5961
}
6062
info = m.InvoiceStatusInfo.from_dict(payload)
6163
self.assertIsNotNone(info.extensions)
6264
assert info.extensions is not None
6365
self.assertEqual(info.extensions["y"], "1")
66+
self.assertEqual(info.extensions["nested"], {"a": 1})
67+
self.assertTrue(info.extensions["flag"])
6468
serialized = info.to_dict()
6569
self.assertIn("extensions", serialized)
70+
self.assertEqual(serialized["extensions"]["nested"], {"a": 1})
71+
self.assertTrue(serialized["extensions"]["flag"])
6672
serialized_all = info.to_dict(omit_none=False)
6773
self.assertIn("details", serialized_all)
6874

@@ -74,6 +80,42 @@ def test_field_mapping(self):
7480
self.assertEqual(parsed.to, 20.0)
7581
self.assertIn("from", parsed.to_dict())
7682

83+
def test_token_permission_type_contains_introspection(self):
84+
values = {item.value for item in m.TokenPermissionType}
85+
self.assertIn("Introspection", values)
86+
87+
def test_token_permission_type_matches_openapi_when_available(self):
88+
repo_root = Path(__file__).resolve().parents[2]
89+
openapi_path = repo_root / "ksef-docs" / "open-api.json"
90+
if not openapi_path.exists():
91+
self.skipTest(
92+
"open-api.json not found; enum compatibility test requires monorepo layout"
93+
)
94+
95+
spec = json.loads(openapi_path.read_text(encoding="utf-8"))
96+
expected = set(spec["components"]["schemas"]["TokenPermissionType"]["enum"])
97+
actual = {item.value for item in m.TokenPermissionType}
98+
self.assertSetEqual(actual, expected)
99+
100+
def test_part_upload_request_headers_keep_non_string_values(self):
101+
payload = {
102+
"headers": {
103+
"X-Request-Id": "abc",
104+
"X-Retry-After": 2,
105+
"X-Meta": {"source": "ksef"},
106+
"X-Enabled": True,
107+
},
108+
"method": "PUT",
109+
"ordinalNumber": 1,
110+
"url": "https://example",
111+
}
112+
parsed = m.PartUploadRequest.from_dict(payload)
113+
self.assertEqual(parsed.headers["X-Request-Id"], "abc")
114+
self.assertEqual(parsed.headers["X-Retry-After"], 2)
115+
self.assertEqual(parsed.headers["X-Meta"], {"source": "ksef"})
116+
self.assertTrue(parsed.headers["X-Enabled"])
117+
self.assertEqual(parsed.to_dict()["headers"], payload["headers"])
118+
77119

78120
if __name__ == "__main__":
79121
unittest.main()

0 commit comments

Comments
 (0)