From e2a9cd5916bf99a14949ca94a0daee686f86c483 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:05:04 +0000 Subject: [PATCH 1/2] [fern-generated] Update SDK Generated by Fern CLI Version: unknown Generators: - fernapi/fern-python-sdk: 5.41.10 --- .fern/metadata.json | 8 +-- poetry.lock | 18 +++---- pyproject.toml | 2 +- src/schematic/__init__.py | 3 ++ src/schematic/core/client_wrapper.py | 4 +- src/schematic/core/http_client.py | 50 ++++++++++++++++- src/schematic/core/pydantic_utilities.py | 2 +- src/schematic/types/__init__.py | 3 ++ .../types/audit_log_list_response_data.py | 6 ++- .../types/audit_log_response_data.py | 6 ++- ..._native_subscription_line_response_data.py | 5 ++ .../catalog_credit_grant_response_data.py | 4 ++ .../credit_event_ledger_response_data.py | 9 +++- .../types/credit_ledger_source_type.py | 8 +++ tests/utils/test_http_client.py | 54 +++++++++++++++++++ 15 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 src/schematic/types/credit_ledger_source_type.py diff --git a/.fern/metadata.json b/.fern/metadata.json index 7cbbcf80..d05d804e 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { "cliVersion": "5.50.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "5.41.4", + "generatorVersion": "5.41.10", "generatorConfig": { "inline_request_params": true, "client": { @@ -52,10 +52,10 @@ }, "pyproject_toml": "[[tool.poetry.include]]\npath = \"src/schematic/datastream/wasm/*.wasm\"\nformat = [\"sdist\", \"wheel\"]\n\n[[tool.mypy.overrides]]\nmodule = \"redis.*\"\nignore_missing_imports = true\n" }, - "originGitCommit": "9c3d404a6ed3ff63811ecbef9e2814ea8607f22d", + "originGitCommit": "6d2cbedf447a3e0916031392e5a8ec1c46d245d3", "originGitCommitIsDirty": false, "invokedBy": "ci", - "requestedVersion": "1.4.6", + "requestedVersion": "1.4.7", "ciProvider": "github", - "sdkVersion": "1.4.6" + "sdkVersion": "1.4.7" } \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index b3cee4a1..a3a66595 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1326,27 +1326,27 @@ crypto = ["cryptography (>=3.4.0)"] [[package]] name = "pytest" -version = "9.1.1" +version = "9.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, - {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, + {file = "pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588"}, + {file = "pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=2" +packaging = ">=24" +pluggy = ">=1.6,<2" +pygments = ">=2.15" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "asynctest ; python_version < \"3.11\"", "attrs (>=23.1)", "coverage (>=7.5)", "hypothesis (>=6.75)", "mock", "numpy (>=1.26)", "pexpect (>=4.9)", "pytest-xdist (>=3.5)", "pyyaml", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" diff --git a/pyproject.toml b/pyproject.toml index b1359d27..c3cd8196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license-files = ["LICENSE"] [tool.poetry] name = "schematichq" -version = "1.4.6" +version = "1.4.7" description = "" readme = "README.md" authors = [] diff --git a/src/schematic/__init__.py b/src/schematic/__init__.py index 03662bfb..af4236d4 100644 --- a/src/schematic/__init__.py +++ b/src/schematic/__init__.py @@ -226,6 +226,7 @@ CreditGrantSortOrder, CreditLeaseResponseData, CreditLedgerEntryKind, + CreditLedgerSourceType, CreditReservationResponseData, CreditSpendPolicy, CreditSpendPolicyResponseData, @@ -1385,6 +1386,7 @@ "CreditGrantSortOrder": ".types", "CreditLeaseResponseData": ".types", "CreditLedgerEntryKind": ".types", + "CreditLedgerSourceType": ".types", "CreditReservationResponseData": ".types", "CreditSpendPolicy": ".types", "CreditSpendPolicyResponseData": ".types", @@ -2427,6 +2429,7 @@ def __dir__(): "CreditGrantSortOrder", "CreditLeaseResponseData", "CreditLedgerEntryKind", + "CreditLedgerSourceType", "CreditReservationResponseData", "CreditSpendPolicy", "CreditSpendPolicyResponseData", diff --git a/src/schematic/core/client_wrapper.py b/src/schematic/core/client_wrapper.py index 36a42667..7b5aeb53 100644 --- a/src/schematic/core/client_wrapper.py +++ b/src/schematic/core/client_wrapper.py @@ -33,12 +33,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "schematichq/1.4.6", + "User-Agent": "schematichq/1.4.7", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "schematichq", - "X-Fern-SDK-Version": "1.4.6", + "X-Fern-SDK-Version": "1.4.7", **(self.get_custom_headers() or {}), } headers["X-Schematic-Api-Key"] = self.api_key diff --git a/src/schematic/core/http_client.py b/src/schematic/core/http_client.py index f47abb6b..c069b268 100644 --- a/src/schematic/core/http_client.py +++ b/src/schematic/core/http_client.py @@ -57,6 +57,49 @@ def get_keepalive_socket_options( return opts +def _get_environment_proxies() -> typing.Dict[str, typing.Optional[str]]: + # httpx skips HTTP(S)_PROXY / ALL_PROXY / NO_PROXY whenever a custom transport is + # passed, so the keepalive default rebuilds those env-derived mounts itself, using + # httpx's own resolver to keep identical semantics (incl. NO_PROXY bypass rules). + try: + from httpx._utils import get_environment_proxies + except (ImportError, AttributeError): + return {} + return get_environment_proxies() + + +def get_keepalive_proxy_mounts( + idle: int = 60, + intvl: int = 30, + cnt: int = 5, +) -> typing.Dict[str, typing.Optional[httpx.BaseTransport]]: + """ + Build ``httpx.Client(mounts=...)`` entries for the proxies configured in the environment, + each carrying the TCP keepalive socket options. ``None`` entries are NO_PROXY bypasses + that fall back to the client's default transport. + """ + socket_options = get_keepalive_socket_options(idle=idle, intvl=intvl, cnt=cnt) + return { + pattern: None if url is None else httpx.HTTPTransport(proxy=httpx.Proxy(url=url), socket_options=socket_options) + for pattern, url in _get_environment_proxies().items() + } + + +def get_async_keepalive_proxy_mounts( + idle: int = 60, + intvl: int = 30, + cnt: int = 5, +) -> typing.Dict[str, typing.Optional[httpx.AsyncBaseTransport]]: + """Async counterpart of ``get_keepalive_proxy_mounts`` for ``httpx.AsyncClient(mounts=...)``.""" + socket_options = get_keepalive_socket_options(idle=idle, intvl=intvl, cnt=cnt) + return { + pattern: None + if url is None + else httpx.AsyncHTTPTransport(proxy=httpx.Proxy(url=url), socket_options=socket_options) + for pattern, url in _get_environment_proxies().items() + } + + def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: """ This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait. @@ -66,9 +109,12 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float retry_after_ms = response_headers.get("retry-after-ms") if retry_after_ms is not None: try: - return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0 - except Exception: + retry_after_ms_value = float(retry_after_ms) + except ValueError: pass + else: + if 0 < retry_after_ms_value < float("inf"): + return retry_after_ms_value / 1000 retry_after = response_headers.get("retry-after") if retry_after is None: diff --git a/src/schematic/core/pydantic_utilities.py b/src/schematic/core/pydantic_utilities.py index 4e63e0fb..803183c0 100644 --- a/src/schematic/core/pydantic_utilities.py +++ b/src/schematic/core/pydantic_utilities.py @@ -486,7 +486,7 @@ def encode_by_type(o: Any) -> Any: def update_forward_refs(model: Type["Model"], **localns: Any) -> None: if IS_PYDANTIC_V2: - model.model_rebuild(raise_errors=False) # type: ignore[attr-defined] + model.model_rebuild(raise_errors=False, _types_namespace=localns or None) # type: ignore[attr-defined] else: model.update_forward_refs(**localns) diff --git a/src/schematic/types/__init__.py b/src/schematic/types/__init__.py index 56566d20..8b393e98 100644 --- a/src/schematic/types/__init__.py +++ b/src/schematic/types/__init__.py @@ -229,6 +229,7 @@ from .credit_grant_sort_order import CreditGrantSortOrder from .credit_lease_response_data import CreditLeaseResponseData from .credit_ledger_entry_kind import CreditLedgerEntryKind + from .credit_ledger_source_type import CreditLedgerSourceType from .credit_reservation_response_data import CreditReservationResponseData from .credit_spend_policy import CreditSpendPolicy from .credit_spend_policy_response_data import CreditSpendPolicyResponseData @@ -802,6 +803,7 @@ "CreditGrantSortOrder": ".credit_grant_sort_order", "CreditLeaseResponseData": ".credit_lease_response_data", "CreditLedgerEntryKind": ".credit_ledger_entry_kind", + "CreditLedgerSourceType": ".credit_ledger_source_type", "CreditReservationResponseData": ".credit_reservation_response_data", "CreditSpendPolicy": ".credit_spend_policy", "CreditSpendPolicyResponseData": ".credit_spend_policy_response_data", @@ -1395,6 +1397,7 @@ def __dir__(): "CreditGrantSortOrder", "CreditLeaseResponseData", "CreditLedgerEntryKind", + "CreditLedgerSourceType", "CreditReservationResponseData", "CreditSpendPolicy", "CreditSpendPolicyResponseData", diff --git a/src/schematic/types/audit_log_list_response_data.py b/src/schematic/types/audit_log_list_response_data.py index e51fd096..8ed5bf09 100644 --- a/src/schematic/types/audit_log_list_response_data.py +++ b/src/schematic/types/audit_log_list_response_data.py @@ -19,7 +19,11 @@ class AuditLogListResponseData(UniversalBaseModel): environment_id: typing.Optional[str] = None id: str method: str - resource_id: typing.Optional[int] = None + resource_id: typing.Optional[int] = pydantic.Field(default=None) + """ + Always null. Use resource_id_string. + """ + resource_id_string: typing.Optional[str] = None resource_name: typing.Optional[str] = None resource_type: typing.Optional[str] = None diff --git a/src/schematic/types/audit_log_response_data.py b/src/schematic/types/audit_log_response_data.py index 20d43e94..81e7bd2a 100644 --- a/src/schematic/types/audit_log_response_data.py +++ b/src/schematic/types/audit_log_response_data.py @@ -21,7 +21,11 @@ class AuditLogResponseData(UniversalBaseModel): id: str method: str req_body: typing.Optional[str] = None - resource_id: typing.Optional[int] = None + resource_id: typing.Optional[int] = pydantic.Field(default=None) + """ + Always null. Use resource_id_string. + """ + resource_id_string: typing.Optional[str] = None resource_name: typing.Optional[str] = None resource_type: typing.Optional[str] = None diff --git a/src/schematic/types/billing_native_subscription_line_response_data.py b/src/schematic/types/billing_native_subscription_line_response_data.py index 41d71322..56ee4c48 100644 --- a/src/schematic/types/billing_native_subscription_line_response_data.py +++ b/src/schematic/types/billing_native_subscription_line_response_data.py @@ -40,6 +40,11 @@ class BillingNativeSubscriptionLineResponseData(UniversalBaseModel): effective_from: dt.datetime effective_to: typing.Optional[dt.datetime] = None + feature_name: typing.Optional[str] = pydantic.Field(default=None) + """ + The feature the line bills for when its price belongs to a plan entitlement, such as seats. Null on a plan's own price, an add-on's and a credit's. + """ + id: str kind: LineKind quantity: int diff --git a/src/schematic/types/catalog_credit_grant_response_data.py b/src/schematic/types/catalog_credit_grant_response_data.py index 2b8605ec..6a4b0fc7 100644 --- a/src/schematic/types/catalog_credit_grant_response_data.py +++ b/src/schematic/types/catalog_credit_grant_response_data.py @@ -28,6 +28,10 @@ class CatalogCreditGrantResponseData(UniversalBaseModel): license_id: typing.Optional[str] = None reset_cadence: typing.Optional[BillingPlanCreditGrantResetCadence] = None scaling: PlanCreditGrantScaling + visible: bool = pydantic.Field() + """ + Whether the catalog shows this credit on the plan. Display-only: a hidden grant is still granted and priced at checkout. + """ if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/schematic/types/credit_event_ledger_response_data.py b/src/schematic/types/credit_event_ledger_response_data.py index f7a7787b..6a4f0f41 100644 --- a/src/schematic/types/credit_event_ledger_response_data.py +++ b/src/schematic/types/credit_event_ledger_response_data.py @@ -14,6 +14,7 @@ from .credit_burn_breakdown_line import CreditBurnBreakdownLine from .credit_event_type import CreditEventType from .credit_ledger_entry_kind import CreditLedgerEntryKind +from .credit_ledger_source_type import CreditLedgerSourceType from .credit_transfer_reason import CreditTransferReason from .credit_usage_reason import CreditUsageReason from .currency import Currency @@ -56,7 +57,13 @@ class CreditEventLedgerResponseData(UniversalBaseModel): plan_id: typing.Optional[str] = None quantity_consumed: typing.Optional[float] = None quantity_remaining_at_zero_out: typing.Optional[float] = None - source_id: int + source_id: int = pydantic.Field() + """ + Always 0. Use source_id_string. + """ + + source_id_string: typing.Optional[str] = None + source_type: typing.Optional[CreditLedgerSourceType] = None to_grant_id: typing.Optional[str] = None transfer_reason: typing.Optional[CreditTransferReason] = None usage_event_id: typing.Optional[str] = None diff --git a/src/schematic/types/credit_ledger_source_type.py b/src/schematic/types/credit_ledger_source_type.py new file mode 100644 index 00000000..80403baf --- /dev/null +++ b/src/schematic/types/credit_ledger_source_type.py @@ -0,0 +1,8 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +CreditLedgerSourceType = typing.Union[ + typing.Literal["billing_charge", "billing_credit_grant", "billing_credit_transfer", "billing_settlement"], + typing.Any, +] diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 51257b77..434299e3 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -11,6 +11,7 @@ HttpClient, _build_url, _merge_headers, + _parse_retry_after, _redact_headers, _should_retry, drop_content_type_without_body, @@ -880,3 +881,56 @@ def test_http_client_sends_single_header_for_case_variant_override() -> None: sent = dummy_client.last_request_kwargs["headers"] assert [k for k in sent if k.lower() == "plaid-secret"] == ["plaid-secret"] assert sent["plaid-secret"] == "override" + + +@pytest.mark.parametrize( + "headers,expected", + [ + ({"retry-after-ms": "1500", "retry-after": "9"}, 1.5), + ({"retry-after-ms": "1"}, 0.001), + ({"retry-after-ms": "3000"}, 3.0), + ({"retry-after-ms": "0", "retry-after": "9"}, 9.0), + ({"retry-after-ms": "-5", "retry-after": "9"}, 9.0), + ({"retry-after-ms": "NaN", "retry-after": "9"}, 9.0), + ({"retry-after-ms": "Infinity", "retry-after": "9"}, 9.0), + ({"retry-after-ms": "1e400", "retry-after": "9"}, 9.0), + ({"retry-after-ms": "not-a-number", "retry-after": "9"}, 9.0), + ({"retry-after": "9"}, 9.0), + ({}, None), + ], +) +def test_parse_retry_after(headers: Dict[str, str], expected: Any) -> None: + assert _parse_retry_after(httpx.Headers(headers)) == expected + + +@patch("schematic.core.http_client.time.sleep", return_value=None) +def test_sync_retry_uses_retry_after_ms_header(mock_sleep: MagicMock) -> None: + mock_client = MagicMock() + mock_client.request.side_effect = [ + httpx.Response(429, headers={"retry-after-ms": "1500", "retry-after": "9"}), + _DummyResponse(), + ] + http_client = _make_sync_http_client(mock_client) + + response = http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + mock_sleep.assert_called_once_with(1.5) + + +@pytest.mark.asyncio +async def test_async_retry_uses_retry_after_ms_header() -> None: + mock_client = MagicMock() + mock_client.request = AsyncMock( + side_effect=[ + httpx.Response(429, headers={"retry-after-ms": "1500", "retry-after": "9"}), + _DummyResponse(), + ] + ) + http_client = _make_async_http_client(mock_client) + + with patch("schematic.core.http_client.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + response = await http_client.request(path="/test", method="GET") + + assert response.status_code == 200 + mock_sleep.assert_called_once_with(1.5) From 20bc3b4b0bc84abc0da42375374178b40f58f063 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:05:04 +0000 Subject: [PATCH 2/2] [fern-replay] advance lockfile --- .fern/replay.lock | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.fern/replay.lock b/.fern/replay.lock index 216acee9..4f70e207 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -126,7 +126,13 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 5.41.4 -current_generation: 98c7766b9d7daae6eae5d58dcefd2e3b843ba6cd + - commit_sha: 5e46d4d4d4fe2d0227eee853538a0474112ed89b + tree_hash: 1df4fd399f4a7f194030fffe1e2addc582a51f99 + timestamp: 2026-10-28T02:05:01.306Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 5.41.10 +current_generation: 5e46d4d4d4fe2d0227eee853538a0474112ed89b patches: [] dismissals: - patch_id: patch-f12c60b5