From 843c8a50ea52f1b7ca40cc98eeb0c116cd9ec131 Mon Sep 17 00:00:00 2001 From: smkc Date: Thu, 19 Feb 2026 19:47:40 +0100 Subject: [PATCH] feat: align python sdk with ksef docs 2.1.2 --- README.md | 2 +- docs/README.md | 2 +- docs/api/invoices.md | 7 +++ src/ksef_client/clients/invoices.py | 68 +++++++++++++++++++++++-- src/ksef_client/openapi_models.py | 5 +- tests/test_clients.py | 78 ++++++++++++++++++++++++++--- tests/test_openapi_models.py | 44 +++++++++++++++- 7 files changed, 191 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1e2c5d2..9796bda 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ SDK zostało zaprojektowane w oparciu o oficjalne biblioteki referencyjne KSeF d ## 🔄 Kompatybilność API KSeF -Aktualna kompatybilność: **KSeF API `v2.1.1`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.1/api-changelog.md)). +Aktualna kompatybilność: **KSeF API `v2.1.2`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.2/api-changelog.md)). ## ✅ Funkcjonalności diff --git a/docs/README.md b/docs/README.md index 4c79a1b..6292ec9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Dokumentacja opisuje **publiczne API** biblioteki `ksef-client-python` (import: Opis kontraktu API (OpenAPI) oraz dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`. -Kompatybilność SDK: **KSeF API `v2.1.1`**. +Kompatybilność SDK: **KSeF API `v2.1.2`**. ## Wymagania diff --git a/docs/api/invoices.md b/docs/api/invoices.md index 734bb2f..76053cc 100644 --- a/docs/api/invoices.md +++ b/docs/api/invoices.md @@ -22,6 +22,10 @@ Endpoint służy do wyszukiwania metadanych faktur. `request_payload` zależy od Typowe zastosowanie: synchronizacja historii metadanych i późniejsze pobieranie treści XML po `ksefNumber`. +Uwaga dla `dateRange`: +- jeśli `dateRange.from` / `dateRange.to` jest podane jako ISO date-time bez offsetu (`YYYY-MM-DDTHH:MM[:SS]`), + SDK normalizuje je do strefy `Europe/Warsaw` i wysyła z jawnie dopisanym offsetem (`+01:00`/`+02:00`). + ## `export_invoices(request_payload, access_token)` Endpoint: `POST /invoices/exports` @@ -33,6 +37,9 @@ Wymagane minimum w `request_payload`: - `encryption.initializationVector` - `filters` (np. `subjectType` + `dateRange`) +Uwaga dla `filters.dateRange`: +- ISO date-time bez offsetu jest normalizowany do `Europe/Warsaw` przed wysyłką requestu. + ## `get_export_status(reference_number, access_token)` Endpoint: `GET /invoices/exports/{referenceNumber}` diff --git a/src/ksef_client/clients/invoices.py b/src/ksef_client/clients/invoices.py index 2e86415..1d59233 100644 --- a/src/ksef_client/clients/invoices.py +++ b/src/ksef_client/clients/invoices.py @@ -1,10 +1,66 @@ from __future__ import annotations +import re +from copy import deepcopy +from datetime import date, datetime, timedelta, timezone from typing import Any from ..models import BinaryContent, InvoiceContent from .base import AsyncBaseApiClient, BaseApiClient +_OFFSET_SUFFIX_RE = re.compile(r"(?:Z|[+-]\d{2}:?\d{2})$") + + +def _last_sunday_of_month(year: int, month: int) -> int: + cursor = date(year, 12, 31) if month == 12 else date(year, month + 1, 1) - timedelta(days=1) + while cursor.weekday() != 6: # Sunday + cursor -= timedelta(days=1) + return cursor.day + + +def _warsaw_offset_for_local_datetime(local_dt: datetime) -> timedelta: + year = local_dt.year + dst_start = datetime(year, 3, _last_sunday_of_month(year, 3), 2, 0, 0) + dst_end = datetime(year, 10, _last_sunday_of_month(year, 10), 3, 0, 0) + if dst_start <= local_dt < dst_end: + return timedelta(hours=2) + return timedelta(hours=1) + + +def _normalize_datetime_without_offset(value: str) -> str: + if "T" not in value or _OFFSET_SUFFIX_RE.search(value): + return value + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return value + if parsed.tzinfo is not None: + return value + offset = _warsaw_offset_for_local_datetime(parsed) + return parsed.replace(tzinfo=timezone(offset)).isoformat() + + +def _normalize_invoice_date_range_payload(request_payload: dict[str, Any]) -> dict[str, Any]: + normalized = deepcopy(request_payload) + date_range_candidates: list[dict[str, Any]] = [] + + top_level = normalized.get("dateRange") + if isinstance(top_level, dict): + date_range_candidates.append(top_level) + + filters = normalized.get("filters") + if isinstance(filters, dict): + nested = filters.get("dateRange") + if isinstance(nested, dict): + date_range_candidates.append(nested) + + for date_range in date_range_candidates: + for field_name in ("from", "to"): + value = date_range.get(field_name) + if isinstance(value, str): + date_range[field_name] = _normalize_datetime_without_offset(value) + return normalized + class InvoicesClient(BaseApiClient): def get_invoice(self, ksef_number: str, *, access_token: str) -> InvoiceContent: @@ -39,6 +95,7 @@ def query_invoice_metadata( page_size: int | None = None, sort_order: str | None = None, ) -> Any: + normalized_payload = _normalize_invoice_date_range_payload(request_payload) params: dict[str, Any] = {} if page_offset is not None: params["pageOffset"] = page_offset @@ -50,15 +107,16 @@ def query_invoice_metadata( "POST", "/invoices/query/metadata", params=params or None, - json=request_payload, + json=normalized_payload, access_token=access_token, ) def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any: + normalized_payload = _normalize_invoice_date_range_payload(request_payload) return self._request_json( "POST", "/invoices/exports", - json=request_payload, + json=normalized_payload, access_token=access_token, expected_status={201, 202}, ) @@ -119,6 +177,7 @@ async def query_invoice_metadata( page_size: int | None = None, sort_order: str | None = None, ) -> Any: + normalized_payload = _normalize_invoice_date_range_payload(request_payload) params: dict[str, Any] = {} if page_offset is not None: params["pageOffset"] = page_offset @@ -130,15 +189,16 @@ async def query_invoice_metadata( "POST", "/invoices/query/metadata", params=params or None, - json=request_payload, + json=normalized_payload, access_token=access_token, ) async def export_invoices(self, request_payload: dict[str, Any], *, access_token: str) -> Any: + normalized_payload = _normalize_invoice_date_range_payload(request_payload) return await self._request_json( "POST", "/invoices/exports", - json=request_payload, + json=normalized_payload, access_token=access_token, expected_status={201, 202}, ) diff --git a/src/ksef_client/openapi_models.py b/src/ksef_client/openapi_models.py index abe1421..6959a40 100644 --- a/src/ksef_client/openapi_models.py +++ b/src/ksef_client/openapi_models.py @@ -671,6 +671,7 @@ class TokenPermissionType(Enum): CREDENTIALSMANAGE = "CredentialsManage" SUBUNITMANAGE = "SubunitManage" ENFORCEMENTOPERATIONS = "EnforcementOperations" + INTROSPECTION = "Introspection" Challenge: TypeAlias = str @@ -1291,7 +1292,7 @@ class InvoiceStatusInfo(OpenApiModel): code: int description: str details: Optional[list[str]] = None - extensions: Optional[dict[str, Optional[str]]] = None + extensions: Optional[dict[str, Any]] = None @dataclass(frozen=True) class OnlineSessionContextLimitsOverride(OpenApiModel): @@ -1329,7 +1330,7 @@ class OpenOnlineSessionResponse(OpenApiModel): @dataclass(frozen=True) class PartUploadRequest(OpenApiModel): - headers: dict[str, Optional[str]] + headers: dict[str, Any] method: str ordinalNumber: int url: str diff --git a/tests/test_clients.py b/tests/test_clients.py index a492aa8..775bf83 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -110,9 +110,30 @@ def test_sessions_client(self): def test_invoices_client(self): client = InvoicesClient(self.http) + query_payload = { + "subjectType": "Subject1", + "dateRange": { + "dateType": "Issue", + "from": "2025-01-02T10:15:00", + "to": "2025-01-02T11:15:00", + }, + } + export_payload = { + "encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"}, + "filters": { + "subjectType": "Subject1", + "dateRange": { + "dateType": "Issue", + "from": "2025-07-02T10:15:00", + "to": "2025-07-02T11:15:00", + }, + }, + } with ( patch.object(client, "_request_raw", Mock(return_value=self.response)), - patch.object(client, "_request_json", Mock(return_value={"ok": True})), + patch.object( + client, "_request_json", Mock(return_value={"ok": True}) + ) as request_json_mock, patch.object(client, "_request_bytes", Mock(return_value=b"bytes")), ): invoice = client.get_invoice("ksef", access_token="token") @@ -120,18 +141,34 @@ def test_invoices_client(self): invoice_bytes = client.get_invoice_bytes("ksef", access_token="token") self.assertEqual(invoice_bytes.sha256_base64, "hash") client.query_invoice_metadata( - {"a": 1}, + query_payload, access_token="token", page_offset=0, page_size=10, sort_order="asc", ) - client.export_invoices({"a": 1}, access_token="token") + client.export_invoices(export_payload, access_token="token") client.get_export_status("ref", access_token="token") client.download_export_part("https://example.com") client.download_package_part("https://example.com") with_hash = client.download_export_part_with_hash("https://example.com") self.assertEqual(with_hash.sha256_base64, "hash") + self.assertEqual( + request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"], + "2025-01-02T10:15:00+01:00", + ) + self.assertEqual( + request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["to"], + "2025-01-02T11:15:00+01:00", + ) + self.assertEqual( + request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"], + "2025-07-02T10:15:00+02:00", + ) + self.assertEqual( + request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["to"], + "2025-07-02T11:15:00+02:00", + ) def test_permissions_client(self): client = PermissionsClient(self.http) @@ -333,25 +370,54 @@ async def test_async_clients(self): await sessions.get_session_upo("ref", "upo", access_token="token") invoices = AsyncInvoicesClient(http) + query_payload = { + "subjectType": "Subject1", + "dateRange": { + "dateType": "Issue", + "from": "2025-01-02T10:15:00", + "to": "2025-01-02T11:15:00", + }, + } + export_payload = { + "encryption": {"encryptedSymmetricKey": "abc", "initializationVector": "def"}, + "filters": { + "subjectType": "Subject1", + "dateRange": { + "dateType": "Issue", + "from": "2025-07-02T10:15:00", + "to": "2025-07-02T11:15:00", + }, + }, + } with ( patch.object(invoices, "_request_raw", AsyncMock(return_value=response)), - patch.object(invoices, "_request_json", AsyncMock(return_value={"ok": True})), + patch.object( + invoices, "_request_json", AsyncMock(return_value={"ok": True}) + ) as request_json_mock, patch.object(invoices, "_request_bytes", AsyncMock(return_value=b"bytes")), ): await invoices.get_invoice("ksef", access_token="token") await invoices.get_invoice_bytes("ksef", access_token="token") await invoices.query_invoice_metadata( - {"a": 1}, + query_payload, access_token="token", page_offset=0, page_size=10, sort_order="asc", ) - await invoices.export_invoices({"a": 1}, access_token="token") + await invoices.export_invoices(export_payload, access_token="token") await invoices.get_export_status("ref", access_token="token") await invoices.download_export_part("https://example.com") await invoices.download_package_part("https://example.com") await invoices.download_export_part_with_hash("https://example.com") + self.assertEqual( + request_json_mock.call_args_list[0].kwargs["json"]["dateRange"]["from"], + "2025-01-02T10:15:00+01:00", + ) + self.assertEqual( + request_json_mock.call_args_list[1].kwargs["json"]["filters"]["dateRange"]["from"], + "2025-07-02T10:15:00+02:00", + ) permissions = AsyncPermissionsClient(http) with patch.object(permissions, "_request_json", AsyncMock(return_value={"ok": True})): diff --git a/tests/test_openapi_models.py b/tests/test_openapi_models.py index 03b92e8..223650f 100644 --- a/tests/test_openapi_models.py +++ b/tests/test_openapi_models.py @@ -1,4 +1,6 @@ +import json import unittest +from pathlib import Path from ksef_client import openapi_models as m @@ -55,14 +57,18 @@ def test_invoice_status_extensions(self): payload = { "code": 200, "description": "ok", - "extensions": {"x": None, "y": "1"}, + "extensions": {"x": None, "y": "1", "nested": {"a": 1}, "flag": True}, } info = m.InvoiceStatusInfo.from_dict(payload) self.assertIsNotNone(info.extensions) assert info.extensions is not None self.assertEqual(info.extensions["y"], "1") + self.assertEqual(info.extensions["nested"], {"a": 1}) + self.assertTrue(info.extensions["flag"]) serialized = info.to_dict() self.assertIn("extensions", serialized) + self.assertEqual(serialized["extensions"]["nested"], {"a": 1}) + self.assertTrue(serialized["extensions"]["flag"]) serialized_all = info.to_dict(omit_none=False) self.assertIn("details", serialized_all) @@ -74,6 +80,42 @@ def test_field_mapping(self): self.assertEqual(parsed.to, 20.0) self.assertIn("from", parsed.to_dict()) + def test_token_permission_type_contains_introspection(self): + values = {item.value for item in m.TokenPermissionType} + self.assertIn("Introspection", values) + + def test_token_permission_type_matches_openapi_when_available(self): + repo_root = Path(__file__).resolve().parents[2] + openapi_path = repo_root / "ksef-docs" / "open-api.json" + if not openapi_path.exists(): + self.skipTest( + "open-api.json not found; enum compatibility test requires monorepo layout" + ) + + spec = json.loads(openapi_path.read_text(encoding="utf-8")) + expected = set(spec["components"]["schemas"]["TokenPermissionType"]["enum"]) + actual = {item.value for item in m.TokenPermissionType} + self.assertSetEqual(actual, expected) + + def test_part_upload_request_headers_keep_non_string_values(self): + payload = { + "headers": { + "X-Request-Id": "abc", + "X-Retry-After": 2, + "X-Meta": {"source": "ksef"}, + "X-Enabled": True, + }, + "method": "PUT", + "ordinalNumber": 1, + "url": "https://example", + } + parsed = m.PartUploadRequest.from_dict(payload) + self.assertEqual(parsed.headers["X-Request-Id"], "abc") + self.assertEqual(parsed.headers["X-Retry-After"], 2) + self.assertEqual(parsed.headers["X-Meta"], {"source": "ksef"}) + self.assertTrue(parsed.headers["X-Enabled"]) + self.assertEqual(parsed.to_dict()["headers"], payload["headers"]) + if __name__ == "__main__": unittest.main()