From 964c77c5799f0d92731ce86390b58d68dc026b4a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:13:57 +0000 Subject: [PATCH 01/22] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 3489822..b9f6416 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml%2Fmiru-server-e8b887c478291aecbbf06e532903242a5d5ff682a2a6814921bb770c97a1753d.yml openapi_spec_hash: b15141c2e0e81b8029c620d4aef4188c -config_hash: c1d1c4ffd4bc69023d6bf98b1bbc4304 +config_hash: 66eb7ff062c14052f1fe5fbf8427e58f From 9109c24323359d720c7c5c966b03cfc548b6da63 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:22:34 +0000 Subject: [PATCH 02/22] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index b9f6416..f2700c5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml%2Fmiru-server-e8b887c478291aecbbf06e532903242a5d5ff682a2a6814921bb770c97a1753d.yml openapi_spec_hash: b15141c2e0e81b8029c620d4aef4188c -config_hash: 66eb7ff062c14052f1fe5fbf8427e58f +config_hash: 3de34a47f7bb67e784afed4170b209b1 From c50bca723dc9398bf97d9db9d881f8090b9deef0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 02:51:57 +0000 Subject: [PATCH 03/22] fix(client): close streams without requiring full consumption --- src/miru_server_sdk/_streaming.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/miru_server_sdk/_streaming.py b/src/miru_server_sdk/_streaming.py index b647204..a4adedd 100644 --- a/src/miru_server_sdk/_streaming.py +++ b/src/miru_server_sdk/_streaming.py @@ -57,9 +57,8 @@ def __stream__(self) -> Iterator[_T]: for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + response.close() def __enter__(self) -> Self: return self @@ -121,9 +120,8 @@ async def __stream__(self) -> AsyncIterator[_T]: async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + await response.aclose() async def __aenter__(self) -> Self: return self From 445023e773a89e857f47d1ca9fb61f0c1f6fde7b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 04:09:27 +0000 Subject: [PATCH 04/22] chore(internal/tests): avoid race condition with implicit client cleanup --- tests/test_client.py | 366 ++++++++++++++++++++++++------------------- 1 file changed, 202 insertions(+), 164 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index cd688ed..3345cae 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -59,51 +59,49 @@ def _get_open_connections(client: Miru | AsyncMiru) -> int: class TestMiru: - client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: Miru) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: Miru) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: Miru) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(api_key="another My API Key") + copied = client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: Miru) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = Miru( @@ -138,6 +136,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = Miru( @@ -175,13 +174,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: Miru) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -192,12 +193,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: Miru) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -254,14 +255,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: Miru) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -272,6 +271,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -281,6 +282,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client) @@ -289,6 +292,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client) @@ -297,6 +302,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -308,14 +315,14 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = Miru( + test_client = Miru( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = Miru( + test_client2 = Miru( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -324,10 +331,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -356,8 +366,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -368,7 +380,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -379,7 +391,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -390,8 +402,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -401,7 +413,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -412,8 +424,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -426,7 +438,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -440,7 +452,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -483,7 +495,7 @@ def test_multipart_repeating_array(self, client: Miru) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: Miru) -> None: class Model1(BaseModel): name: str @@ -492,12 +504,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: Miru) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -508,18 +520,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: Miru) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -535,7 +547,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -547,6 +559,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(MIRU_BASE_URL="http://localhost:5000/from/env"): client = Miru(api_key=api_key, _strict_response_validation=True) @@ -560,6 +574,8 @@ def test_base_url_env(self) -> None: client = Miru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") assert str(client.base_url).startswith("https://configs.api.miruml.com/v1") + client.close() + @pytest.mark.parametrize( "client", [ @@ -582,6 +598,7 @@ def test_base_url_trailing_slash(self, client: Miru) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -605,6 +622,7 @@ def test_base_url_no_trailing_slash(self, client: Miru) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -628,35 +646,36 @@ def test_absolute_request_url(self, client: Miru) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: Miru) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -676,11 +695,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -703,9 +725,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = Miru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: Miru + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -719,7 +741,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.config_instances.with_streaming_response.retrieve(config_instance_id="cfg_inst_123").__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -728,7 +750,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.config_instances.with_streaming_response.retrieve(config_instance_id="cfg_inst_123").__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -832,83 +854,77 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: Miru) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: Miru) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncMiru: - client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncMiru) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(api_key="another My API Key") + copied = async_client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert async_client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncMiru) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncMiru( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) @@ -941,8 +957,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncMiru( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} ) @@ -978,13 +995,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncMiru) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -995,12 +1014,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncMiru) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1057,12 +1076,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncMiru) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1077,6 +1096,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1088,6 +1109,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncMiru( @@ -1098,6 +1121,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncMiru( @@ -1108,6 +1133,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1118,15 +1145,15 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncMiru( + async def test_default_headers_option(self) -> None: + test_client = AsyncMiru( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncMiru( + test_client2 = AsyncMiru( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -1135,10 +1162,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1149,7 +1179,7 @@ def test_validate_headers(self) -> None: client2 = AsyncMiru(base_url=base_url, api_key=None, _strict_response_validation=True) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncMiru( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} ) @@ -1167,8 +1197,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1179,7 +1211,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1190,7 +1222,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1201,8 +1233,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1212,7 +1244,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1223,8 +1255,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Miru) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1237,7 +1269,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1251,7 +1283,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1294,7 +1326,7 @@ def test_multipart_repeating_array(self, async_client: AsyncMiru) -> None: ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: class Model1(BaseModel): name: str @@ -1303,12 +1335,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1319,18 +1351,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncMiru + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1346,11 +1380,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncMiru(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True) assert client.base_url == "https://example.com/from_init/" @@ -1358,7 +1392,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(MIRU_BASE_URL="http://localhost:5000/from/env"): client = AsyncMiru(api_key=api_key, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1371,6 +1407,8 @@ def test_base_url_env(self) -> None: client = AsyncMiru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") assert str(client.base_url).startswith("https://configs.api.miruml.com/v1") + await client.close() + @pytest.mark.parametrize( "client", [ @@ -1384,7 +1422,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncMiru) -> None: + async def test_base_url_trailing_slash(self, client: AsyncMiru) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1393,6 +1431,7 @@ def test_base_url_trailing_slash(self, client: AsyncMiru) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1407,7 +1446,7 @@ def test_base_url_trailing_slash(self, client: AsyncMiru) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncMiru) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncMiru) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1416,6 +1455,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncMiru) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1430,7 +1470,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncMiru) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncMiru) -> None: + async def test_absolute_request_url(self, client: AsyncMiru) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1439,37 +1479,37 @@ def test_absolute_request_url(self, client: AsyncMiru) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1478,7 +1518,6 @@ async def test_client_max_retries_validation(self) -> None: AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1490,11 +1529,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1517,13 +1559,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncMiru(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncMiru + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1536,7 +1577,7 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, config_instance_id="cfg_inst_123" ).__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1547,12 +1588,11 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, await async_client.config_instances.with_streaming_response.retrieve( config_instance_id="cfg_inst_123" ).__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1584,7 +1624,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncMiru, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1610,7 +1649,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("miru_server_sdk._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncMiru, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1660,26 +1698,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncMiru) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) From 5c82d3a7b9f90620c68e850441cbd204d475e8d0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 06:04:32 +0000 Subject: [PATCH 05/22] chore(internal): grammar fix (it's -> its) --- src/miru_server_sdk/_utils/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/miru_server_sdk/_utils/_utils.py b/src/miru_server_sdk/_utils/_utils.py index 50d5926..eec7f4a 100644 --- a/src/miru_server_sdk/_utils/_utils.py +++ b/src/miru_server_sdk/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input From 80b7c35b8dbe72ecf05cca08217b934eb8bef3a5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 05:55:26 +0000 Subject: [PATCH 06/22] chore(package): drop Python 3.8 support --- README.md | 4 ++-- pyproject.toml | 5 ++--- src/miru_server_sdk/_utils/_sync.py | 34 +++-------------------------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 651aac8..9ff949a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/miru_server_sdk.svg?label=pypi%20(stable))](https://pypi.org/project/miru_server_sdk/) -The Miru Python library provides convenient access to the Miru REST API from any Python 3.8+ +The Miru Python library provides convenient access to the Miru REST API from any Python 3.9+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). @@ -376,7 +376,7 @@ print(miru_server_sdk.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 7beb352..baa9e7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -141,7 +140,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/src/miru_server_sdk/_utils/_sync.py b/src/miru_server_sdk/_utils/_sync.py index ad7ec71..f6027c1 100644 --- a/src/miru_server_sdk/_utils/_sync.py +++ b/src/miru_server_sdk/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: From 6e10abeb80e20e1d64eb9e1d251bd2d426ed62ad Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 05:56:03 +0000 Subject: [PATCH 07/22] fix: compat with Python 3.14 --- src/miru_server_sdk/_models.py | 11 ++++++++--- tests/test_models.py | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/miru_server_sdk/_models.py b/src/miru_server_sdk/_models.py index 6a3cd1d..fcec2cf 100644 --- a/src/miru_server_sdk/_models.py +++ b/src/miru_server_sdk/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -573,6 +574,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +619,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +674,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/tests/test_models.py b/tests/test_models.py index f658293..d074411 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from miru_server_sdk._utils import PropertyInfo from miru_server_sdk._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from miru_server_sdk._models import BaseModel, construct_type +from miru_server_sdk._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") From b71ebf96149950711b88780da4dce30a1e250e02 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 05:30:17 +0000 Subject: [PATCH 08/22] fix(compat): update signatures of `model_dump` and `model_dump_json` for Pydantic v1 --- src/miru_server_sdk/_models.py | 41 ++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/miru_server_sdk/_models.py b/src/miru_server_sdk/_models.py index fcec2cf..ca9500b 100644 --- a/src/miru_server_sdk/_models.py +++ b/src/miru_server_sdk/_models.py @@ -257,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -273,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -299,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -315,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -355,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, From 6df3ecd00b28abb16c06cae6c13b8728445ace6a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 22 Nov 2025 04:55:51 +0000 Subject: [PATCH 09/22] chore: add Python 3.14 classifier and testing --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index baa9e7f..c2813ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From ab93bca51d143a6e8d26a05947e538d15ee751ea Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:41:12 +0000 Subject: [PATCH 10/22] fix: ensure streams are always closed --- src/miru_server_sdk/_streaming.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/miru_server_sdk/_streaming.py b/src/miru_server_sdk/_streaming.py index a4adedd..8ea6dbe 100644 --- a/src/miru_server_sdk/_streaming.py +++ b/src/miru_server_sdk/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self From d644966063e8b3e17ca407ad4062be2874e07878 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:42:15 +0000 Subject: [PATCH 11/22] chore(deps): mypy 1.18.1 has a regression, pin to 1.17 --- pyproject.toml | 2 +- requirements-dev.lock | 4 +++- requirements.lock | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c2813ef..6b16f39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index b668252..71a88de 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -72,7 +72,7 @@ mdurl==0.1.2 multidict==6.4.4 # via aiohttp # via yarl -mypy==1.14.1 +mypy==1.17.0 mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 @@ -81,6 +81,8 @@ nox==2023.4.22 packaging==23.2 # via nox # via pytest +pathspec==0.12.1 + # via mypy platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 diff --git a/requirements.lock b/requirements.lock index 1e316ae..075e1e1 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,21 +55,21 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via miru-server-sdk -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic sniffio==1.3.0 # via anyio # via miru-server-sdk -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via anyio # via miru-server-sdk # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic yarl==1.20.0 # via aiohttp From f8405973872ef23e5294cf061d7794497e15a1ec Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:12:04 +0000 Subject: [PATCH 12/22] chore: update lockfile --- pyproject.toml | 14 +++--- requirements-dev.lock | 108 +++++++++++++++++++++++------------------- requirements.lock | 31 ++++++------ 3 files changed, 83 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6b16f39..62cff99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "MIT" authors = [ { name = "Miru", email = "ben@miruml.com" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", diff --git a/requirements-dev.lock b/requirements-dev.lock index 71a88de..1ba21a7 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via miru-server-sdk -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via miru-server-sdk -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via miru-server-sdk -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,82 +63,87 @@ httpx==0.28.1 # via respx httpx-aiohttp==0.1.9 # via miru-server-sdk -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest pathspec==0.12.1 # via mypy -platformdirs==3.11.0 +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via miru-server-sdk -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via miru-server-sdk -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via miru-server-sdk # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 075e1e1..bf0cdb2 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via miru-server-sdk -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via miru-server-sdk async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via miru-server-sdk -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,25 +45,26 @@ httpx==0.28.1 # via miru-server-sdk httpx-aiohttp==0.1.9 # via miru-server-sdk -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 # via miru-server-sdk pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via miru-server-sdk typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via miru-server-sdk # via multidict # via pydantic @@ -71,5 +72,5 @@ typing-extensions==4.15.0 # via typing-inspection typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp From ee97b11addd6b183e49a28d6ccbc7cb73b9247dd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:21:44 +0000 Subject: [PATCH 13/22] chore(docs): use environment variables for authentication in code snippets --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ff949a..611322a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ pip install miru_server_sdk[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from miru_server_sdk import DefaultAioHttpClient from miru_server_sdk import AsyncMiru @@ -94,7 +95,7 @@ from miru_server_sdk import AsyncMiru async def main() -> None: async with AsyncMiru( - api_key="My API Key", + api_key=os.environ.get("MIRU_SERVER_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: config_instance = await client.config_instances.retrieve( From 6b7bd87fd125f8ac6205af2d289e75036df693e4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 05:13:22 +0000 Subject: [PATCH 14/22] refactor(api)!: change route to be /beta instead of /v1 --- .stats.yml | 2 +- src/miru_server_sdk/_client.py | 8 ++++---- tests/test_client.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index f2700c5..b4e52eb 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml%2Fmiru-server-e8b887c478291aecbbf06e532903242a5d5ff682a2a6814921bb770c97a1753d.yml openapi_spec_hash: b15141c2e0e81b8029c620d4aef4188c -config_hash: 3de34a47f7bb67e784afed4170b209b1 +config_hash: 95b6fa4e87744247f5bdbf841c16ce0a diff --git a/src/miru_server_sdk/_client.py b/src/miru_server_sdk/_client.py index b49bf4e..9b205b9 100644 --- a/src/miru_server_sdk/_client.py +++ b/src/miru_server_sdk/_client.py @@ -43,10 +43,10 @@ ] ENVIRONMENTS: Dict[str, str] = { - "prod": "https://configs.api.miruml.com/v1", - "uat": "https://uat.api.miruml.com/v1", - "staging": "https://configs.dev.api.miruml.com/v1", - "local": "http://localhost:8080/v1", + "prod": "https://configs.api.miruml.com/beta", + "uat": "https://uat.api.miruml.com/beta", + "staging": "https://configs.dev.api.miruml.com/beta", + "local": "http://localhost:8080/beta", } diff --git a/tests/test_client.py b/tests/test_client.py index 3345cae..3780af6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -572,7 +572,7 @@ def test_base_url_env(self) -> None: Miru(api_key=api_key, _strict_response_validation=True, environment="prod") client = Miru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") - assert str(client.base_url).startswith("https://configs.api.miruml.com/v1") + assert str(client.base_url).startswith("https://configs.api.miruml.com/beta") client.close() @@ -1405,7 +1405,7 @@ async def test_base_url_env(self) -> None: AsyncMiru(api_key=api_key, _strict_response_validation=True, environment="prod") client = AsyncMiru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") - assert str(client.base_url).startswith("https://configs.api.miruml.com/v1") + assert str(client.base_url).startswith("https://configs.api.miruml.com/beta") await client.close() From 7769f807b07bc2d6b611766e1e5d4545289d558d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 05:22:18 +0000 Subject: [PATCH 15/22] fix(types): allow pyright to infer TypedDict types within SequenceNotStr --- src/miru_server_sdk/_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/miru_server_sdk/_types.py b/src/miru_server_sdk/_types.py index 09529e8..4850495 100644 --- a/src/miru_server_sdk/_types.py +++ b/src/miru_server_sdk/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case From 37f1eae5abbe16ad18e05bdf8bfa47509304c7ee Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 05:24:43 +0000 Subject: [PATCH 16/22] chore: add missing docstrings --- src/miru_server_sdk/types/deployment_validate_params.py | 4 ++++ .../types/deployment_validate_webhook_event.py | 2 ++ src/miru_server_sdk/types/unwrap_webhook_event.py | 2 ++ 3 files changed, 8 insertions(+) diff --git a/src/miru_server_sdk/types/deployment_validate_params.py b/src/miru_server_sdk/types/deployment_validate_params.py index fa95a39..7876ded 100644 --- a/src/miru_server_sdk/types/deployment_validate_params.py +++ b/src/miru_server_sdk/types/deployment_validate_params.py @@ -25,6 +25,8 @@ class DeploymentValidateParams(TypedDict, total=False): class ConfigInstanceParameter(TypedDict, total=False): + """The validation error for a parameter in the config instance.""" + message: Required[str] """An error message displayed for an individual parameter.""" @@ -33,6 +35,8 @@ class ConfigInstanceParameter(TypedDict, total=False): class ConfigInstance(TypedDict, total=False): + """The validation errors(s) for a specific config instance in the deployment.""" + id: Required[str] """ID of the config instance.""" diff --git a/src/miru_server_sdk/types/deployment_validate_webhook_event.py b/src/miru_server_sdk/types/deployment_validate_webhook_event.py index 2d96225..948d628 100644 --- a/src/miru_server_sdk/types/deployment_validate_webhook_event.py +++ b/src/miru_server_sdk/types/deployment_validate_webhook_event.py @@ -25,6 +25,8 @@ class DataDeployment(BaseModel): class Data(BaseModel): + """The data associated with the event""" + deployment: DataDeployment diff --git a/src/miru_server_sdk/types/unwrap_webhook_event.py b/src/miru_server_sdk/types/unwrap_webhook_event.py index fc66adb..4c3be7e 100644 --- a/src/miru_server_sdk/types/unwrap_webhook_event.py +++ b/src/miru_server_sdk/types/unwrap_webhook_event.py @@ -25,6 +25,8 @@ class DataDeployment(BaseModel): class Data(BaseModel): + """The data associated with the event""" + deployment: DataDeployment From 1d7820f56b688e75988288e86b2a6eb62657681f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 05:08:44 +0000 Subject: [PATCH 17/22] chore(internal): add missing files argument to base client --- src/miru_server_sdk/_base_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/miru_server_sdk/_base_client.py b/src/miru_server_sdk/_base_client.py index 4b80ce9..321f0e8 100644 --- a/src/miru_server_sdk/_base_client.py +++ b/src/miru_server_sdk/_base_client.py @@ -1247,9 +1247,12 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1767,9 +1770,12 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return await self.request(cast_to, opts) async def put( From 4ebe2315fe940766e2888b4cda52c7d9799e6132 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 07:30:48 +0000 Subject: [PATCH 18/22] chore: speedup initial import --- src/miru_server_sdk/_client.py | 242 ++++++++++++++++++++++++++------- 1 file changed, 192 insertions(+), 50 deletions(-) diff --git a/src/miru_server_sdk/_client.py b/src/miru_server_sdk/_client.py index 9b205b9..57b3fa3 100644 --- a/src/miru_server_sdk/_client.py +++ b/src/miru_server_sdk/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Dict, Mapping, cast +from typing import TYPE_CHECKING, Any, Dict, Mapping, cast from typing_extensions import Self, Literal, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import devices, releases, webhooks, deployments, config_instances from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import MiruError, APIStatusError from ._base_client import ( @@ -30,6 +30,14 @@ AsyncAPIClient, ) +if TYPE_CHECKING: + from .resources import devices, releases, deployments, config_instances + from .resources.devices import DevicesResource, AsyncDevicesResource + from .resources.releases import ReleasesResource, AsyncReleasesResource + from .resources.webhooks import WebhooksResource, AsyncWebhooksResource + from .resources.deployments import DeploymentsResource, AsyncDeploymentsResource + from .resources.config_instances import ConfigInstancesResource, AsyncConfigInstancesResource + __all__ = [ "ENVIRONMENTS", "Timeout", @@ -51,14 +59,6 @@ class Miru(SyncAPIClient): - config_instances: config_instances.ConfigInstancesResource - deployments: deployments.DeploymentsResource - devices: devices.DevicesResource - releases: releases.ReleasesResource - webhooks: webhooks.WebhooksResource - with_raw_response: MiruWithRawResponse - with_streaming_response: MiruWithStreamedResponse - # client options api_key: str host: str @@ -152,13 +152,43 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.config_instances = config_instances.ConfigInstancesResource(self) - self.deployments = deployments.DeploymentsResource(self) - self.devices = devices.DevicesResource(self) - self.releases = releases.ReleasesResource(self) - self.webhooks = webhooks.WebhooksResource(self) - self.with_raw_response = MiruWithRawResponse(self) - self.with_streaming_response = MiruWithStreamedResponse(self) + @cached_property + def config_instances(self) -> ConfigInstancesResource: + from .resources.config_instances import ConfigInstancesResource + + return ConfigInstancesResource(self) + + @cached_property + def deployments(self) -> DeploymentsResource: + from .resources.deployments import DeploymentsResource + + return DeploymentsResource(self) + + @cached_property + def devices(self) -> DevicesResource: + from .resources.devices import DevicesResource + + return DevicesResource(self) + + @cached_property + def releases(self) -> ReleasesResource: + from .resources.releases import ReleasesResource + + return ReleasesResource(self) + + @cached_property + def webhooks(self) -> WebhooksResource: + from .resources.webhooks import WebhooksResource + + return WebhooksResource(self) + + @cached_property + def with_raw_response(self) -> MiruWithRawResponse: + return MiruWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MiruWithStreamedResponse: + return MiruWithStreamedResponse(self) @property @override @@ -272,14 +302,6 @@ def _make_status_error( class AsyncMiru(AsyncAPIClient): - config_instances: config_instances.AsyncConfigInstancesResource - deployments: deployments.AsyncDeploymentsResource - devices: devices.AsyncDevicesResource - releases: releases.AsyncReleasesResource - webhooks: webhooks.AsyncWebhooksResource - with_raw_response: AsyncMiruWithRawResponse - with_streaming_response: AsyncMiruWithStreamedResponse - # client options api_key: str host: str @@ -373,13 +395,43 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.config_instances = config_instances.AsyncConfigInstancesResource(self) - self.deployments = deployments.AsyncDeploymentsResource(self) - self.devices = devices.AsyncDevicesResource(self) - self.releases = releases.AsyncReleasesResource(self) - self.webhooks = webhooks.AsyncWebhooksResource(self) - self.with_raw_response = AsyncMiruWithRawResponse(self) - self.with_streaming_response = AsyncMiruWithStreamedResponse(self) + @cached_property + def config_instances(self) -> AsyncConfigInstancesResource: + from .resources.config_instances import AsyncConfigInstancesResource + + return AsyncConfigInstancesResource(self) + + @cached_property + def deployments(self) -> AsyncDeploymentsResource: + from .resources.deployments import AsyncDeploymentsResource + + return AsyncDeploymentsResource(self) + + @cached_property + def devices(self) -> AsyncDevicesResource: + from .resources.devices import AsyncDevicesResource + + return AsyncDevicesResource(self) + + @cached_property + def releases(self) -> AsyncReleasesResource: + from .resources.releases import AsyncReleasesResource + + return AsyncReleasesResource(self) + + @cached_property + def webhooks(self) -> AsyncWebhooksResource: + from .resources.webhooks import AsyncWebhooksResource + + return AsyncWebhooksResource(self) + + @cached_property + def with_raw_response(self) -> AsyncMiruWithRawResponse: + return AsyncMiruWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMiruWithStreamedResponse: + return AsyncMiruWithStreamedResponse(self) @property @override @@ -493,37 +545,127 @@ def _make_status_error( class MiruWithRawResponse: + _client: Miru + def __init__(self, client: Miru) -> None: - self.config_instances = config_instances.ConfigInstancesResourceWithRawResponse(client.config_instances) - self.deployments = deployments.DeploymentsResourceWithRawResponse(client.deployments) - self.devices = devices.DevicesResourceWithRawResponse(client.devices) - self.releases = releases.ReleasesResourceWithRawResponse(client.releases) + self._client = client + + @cached_property + def config_instances(self) -> config_instances.ConfigInstancesResourceWithRawResponse: + from .resources.config_instances import ConfigInstancesResourceWithRawResponse + + return ConfigInstancesResourceWithRawResponse(self._client.config_instances) + + @cached_property + def deployments(self) -> deployments.DeploymentsResourceWithRawResponse: + from .resources.deployments import DeploymentsResourceWithRawResponse + + return DeploymentsResourceWithRawResponse(self._client.deployments) + + @cached_property + def devices(self) -> devices.DevicesResourceWithRawResponse: + from .resources.devices import DevicesResourceWithRawResponse + + return DevicesResourceWithRawResponse(self._client.devices) + + @cached_property + def releases(self) -> releases.ReleasesResourceWithRawResponse: + from .resources.releases import ReleasesResourceWithRawResponse + + return ReleasesResourceWithRawResponse(self._client.releases) class AsyncMiruWithRawResponse: + _client: AsyncMiru + def __init__(self, client: AsyncMiru) -> None: - self.config_instances = config_instances.AsyncConfigInstancesResourceWithRawResponse(client.config_instances) - self.deployments = deployments.AsyncDeploymentsResourceWithRawResponse(client.deployments) - self.devices = devices.AsyncDevicesResourceWithRawResponse(client.devices) - self.releases = releases.AsyncReleasesResourceWithRawResponse(client.releases) + self._client = client + + @cached_property + def config_instances(self) -> config_instances.AsyncConfigInstancesResourceWithRawResponse: + from .resources.config_instances import AsyncConfigInstancesResourceWithRawResponse + + return AsyncConfigInstancesResourceWithRawResponse(self._client.config_instances) + + @cached_property + def deployments(self) -> deployments.AsyncDeploymentsResourceWithRawResponse: + from .resources.deployments import AsyncDeploymentsResourceWithRawResponse + + return AsyncDeploymentsResourceWithRawResponse(self._client.deployments) + + @cached_property + def devices(self) -> devices.AsyncDevicesResourceWithRawResponse: + from .resources.devices import AsyncDevicesResourceWithRawResponse + + return AsyncDevicesResourceWithRawResponse(self._client.devices) + + @cached_property + def releases(self) -> releases.AsyncReleasesResourceWithRawResponse: + from .resources.releases import AsyncReleasesResourceWithRawResponse + + return AsyncReleasesResourceWithRawResponse(self._client.releases) class MiruWithStreamedResponse: + _client: Miru + def __init__(self, client: Miru) -> None: - self.config_instances = config_instances.ConfigInstancesResourceWithStreamingResponse(client.config_instances) - self.deployments = deployments.DeploymentsResourceWithStreamingResponse(client.deployments) - self.devices = devices.DevicesResourceWithStreamingResponse(client.devices) - self.releases = releases.ReleasesResourceWithStreamingResponse(client.releases) + self._client = client + + @cached_property + def config_instances(self) -> config_instances.ConfigInstancesResourceWithStreamingResponse: + from .resources.config_instances import ConfigInstancesResourceWithStreamingResponse + + return ConfigInstancesResourceWithStreamingResponse(self._client.config_instances) + + @cached_property + def deployments(self) -> deployments.DeploymentsResourceWithStreamingResponse: + from .resources.deployments import DeploymentsResourceWithStreamingResponse + + return DeploymentsResourceWithStreamingResponse(self._client.deployments) + + @cached_property + def devices(self) -> devices.DevicesResourceWithStreamingResponse: + from .resources.devices import DevicesResourceWithStreamingResponse + + return DevicesResourceWithStreamingResponse(self._client.devices) + + @cached_property + def releases(self) -> releases.ReleasesResourceWithStreamingResponse: + from .resources.releases import ReleasesResourceWithStreamingResponse + + return ReleasesResourceWithStreamingResponse(self._client.releases) class AsyncMiruWithStreamedResponse: + _client: AsyncMiru + def __init__(self, client: AsyncMiru) -> None: - self.config_instances = config_instances.AsyncConfigInstancesResourceWithStreamingResponse( - client.config_instances - ) - self.deployments = deployments.AsyncDeploymentsResourceWithStreamingResponse(client.deployments) - self.devices = devices.AsyncDevicesResourceWithStreamingResponse(client.devices) - self.releases = releases.AsyncReleasesResourceWithStreamingResponse(client.releases) + self._client = client + + @cached_property + def config_instances(self) -> config_instances.AsyncConfigInstancesResourceWithStreamingResponse: + from .resources.config_instances import AsyncConfigInstancesResourceWithStreamingResponse + + return AsyncConfigInstancesResourceWithStreamingResponse(self._client.config_instances) + + @cached_property + def deployments(self) -> deployments.AsyncDeploymentsResourceWithStreamingResponse: + from .resources.deployments import AsyncDeploymentsResourceWithStreamingResponse + + return AsyncDeploymentsResourceWithStreamingResponse(self._client.deployments) + + @cached_property + def devices(self) -> devices.AsyncDevicesResourceWithStreamingResponse: + from .resources.devices import AsyncDevicesResourceWithStreamingResponse + + return AsyncDevicesResourceWithStreamingResponse(self._client.devices) + + @cached_property + def releases(self) -> releases.AsyncReleasesResourceWithStreamingResponse: + from .resources.releases import AsyncReleasesResourceWithStreamingResponse + + return AsyncReleasesResourceWithStreamingResponse(self._client.releases) Client = Miru From 4c6896709773ce6fee0641f107ab0c7364f9716d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 08:48:56 +0000 Subject: [PATCH 19/22] fix: use async_to_httpx_files in patch method --- src/miru_server_sdk/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/miru_server_sdk/_base_client.py b/src/miru_server_sdk/_base_client.py index 321f0e8..99bcef5 100644 --- a/src/miru_server_sdk/_base_client.py +++ b/src/miru_server_sdk/_base_client.py @@ -1774,7 +1774,7 @@ async def patch( options: RequestOptions = {}, ) -> ResponseT: opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) From 22cd02f5f184a085b365aca7c1f1f6959be0fbcb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:30:56 +0000 Subject: [PATCH 20/22] chore(internal): add `--fix` argument to lint script --- scripts/lint | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lint b/scripts/lint index e7476c4..bec0398 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import miru_server_sdk' From c4127dd3a6e14e10757ea6f29929370231c1ceaf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:36:30 +0000 Subject: [PATCH 21/22] refactor(api): api domain from miruml to mirurobotics --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .stats.yml | 2 +- CONTRIBUTING.md | 4 ++-- LICENSE | 2 +- README.md | 8 ++++---- SECURITY.md | 2 +- pyproject.toml | 8 ++++---- src/miru_server_sdk/_client.py | 10 +++++----- src/miru_server_sdk/resources/config_instances.py | 8 ++++---- src/miru_server_sdk/resources/deployments.py | 8 ++++---- src/miru_server_sdk/resources/devices.py | 8 ++++---- src/miru_server_sdk/resources/releases.py | 8 ++++---- tests/test_client.py | 4 ++-- 14 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 0eb2ff8..1659885 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,6 +1,6 @@ # This workflow is triggered when a GitHub release is created. # It can also be run manually to re-publish to PyPI in case it failed for some reason. -# You can run this workflow by navigating to https://www.github.com/miruml/python-server-sdk/actions/workflows/publish-pypi.yml +# You can run this workflow by navigating to https://www.github.com/mirurobotics/python-server-sdk/actions/workflows/publish-pypi.yml name: Publish PyPI on: workflow_dispatch: diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 5bcf955..a40483f 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -9,7 +9,7 @@ jobs: release_doctor: name: release doctor runs-on: ubuntu-latest - if: github.repository == 'miruml/python-server-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + if: github.repository == 'mirurobotics/python-server-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - uses: actions/checkout@v4 diff --git a/.stats.yml b/.stats.yml index b4e52eb..e56115c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/miru-ml%2Fmiru-server-e8b887c478291aecbbf06e532903242a5d5ff682a2a6814921bb770c97a1753d.yml openapi_spec_hash: b15141c2e0e81b8029c620d4aef4188c -config_hash: 95b6fa4e87744247f5bdbf841c16ce0a +config_hash: 0c281c7956e6819225d871f6cfc91578 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 993bc19..c4dacec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ If you’d like to use the repository from source, you can either install from g To install via git: ```sh -$ pip install git+ssh://git@github.com/miruml/python-server-sdk.git +$ pip install git+ssh://git@github.com/mirurobotics/python-server-sdk.git ``` Alternatively, you can build from source and install the wheel file: @@ -120,7 +120,7 @@ the changes aren't made through the automated pipeline, you may want to make rel ### Publish with a GitHub workflow -You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/miruml/python-server-sdk/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/mirurobotics/python-server-sdk/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. ### Publish manually diff --git a/LICENSE b/LICENSE index 9600cfc..7d9645a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2025 miru +Copyright 2026 miru Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 611322a..75e85e7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It is generated with [Stainless](https://www.stainless.com/). ## Documentation -The REST API documentation can be found on [docs.miruml.com](https://docs.miruml.com/home). The full API of this library can be found in [api.md](api.md). +The REST API documentation can be found on [docs.mirurobotics.com](https://docs.mirurobotics.com/home). The full API of this library can be found in [api.md](api.md). ## Installation @@ -254,9 +254,9 @@ config_instance = response.parse() # get the object that `config_instances.retr print(config_instance.id) ``` -These methods return an [`APIResponse`](https://github.com/miruml/python-server-sdk/tree/main/src/miru_server_sdk/_response.py) object. +These methods return an [`APIResponse`](https://github.com/mirurobotics/python-server-sdk/tree/main/src/miru_server_sdk/_response.py) object. -The async client returns an [`AsyncAPIResponse`](https://github.com/miruml/python-server-sdk/tree/main/src/miru_server_sdk/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. +The async client returns an [`AsyncAPIResponse`](https://github.com/mirurobotics/python-server-sdk/tree/main/src/miru_server_sdk/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. #### `.with_streaming_response` @@ -362,7 +362,7 @@ This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) con We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -We are keen for your feedback; please open an [issue](https://www.github.com/miruml/python-server-sdk/issues) with questions, bugs, or suggestions. +We are keen for your feedback; please open an [issue](https://www.github.com/mirurobotics/python-server-sdk/issues) with questions, bugs, or suggestions. ### Determining the installed version diff --git a/SECURITY.md b/SECURITY.md index f5f749d..22c0f6d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,7 @@ or products provided by Miru, please follow the respective company's security re ### Miru Terms and Policies -Please contact ben@miruml.com for any questions or concerns regarding the security of our services. +Please contact ben@mirurobotics.com for any questions or concerns regarding the security of our services. --- diff --git a/pyproject.toml b/pyproject.toml index 62cff99..0c3af8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "The official Python library for the miru API" dynamic = ["readme"] license = "MIT" authors = [ -{ name = "Miru", email = "ben@miruml.com" }, +{ name = "Miru", email = "ben@mirurobotics.com" }, ] dependencies = [ @@ -37,8 +37,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/miruml/python-server-sdk" -Repository = "https://github.com/miruml/python-server-sdk" +Homepage = "https://github.com/mirurobotics/python-server-sdk" +Repository = "https://github.com/mirurobotics/python-server-sdk" [project.optional-dependencies] aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] @@ -126,7 +126,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/miruml/python-server-sdk/tree/main/\g<2>)' +replacement = '[\1](https://github.com/mirurobotics/python-server-sdk/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/miru_server_sdk/_client.py b/src/miru_server_sdk/_client.py index 57b3fa3..e5de495 100644 --- a/src/miru_server_sdk/_client.py +++ b/src/miru_server_sdk/_client.py @@ -51,9 +51,9 @@ ] ENVIRONMENTS: Dict[str, str] = { - "prod": "https://configs.api.miruml.com/beta", - "uat": "https://uat.api.miruml.com/beta", - "staging": "https://configs.dev.api.miruml.com/beta", + "prod": "https://api.mirurobotics.com/beta", + "uat": "https://uat.api.mirurobotics.com/beta", + "staging": "https://staging.api.mirurobotics.com/beta", "local": "http://localhost:8080/beta", } @@ -108,7 +108,7 @@ def __init__( self.api_key = api_key if host is None: - host = os.environ.get("MIRU_SERVER_HOST") or "configs.api.miruml.com" + host = os.environ.get("MIRU_SERVER_HOST") or "api.mirurobotics.com" self.host = host if version is None: @@ -351,7 +351,7 @@ def __init__( self.api_key = api_key if host is None: - host = os.environ.get("MIRU_SERVER_HOST") or "configs.api.miruml.com" + host = os.environ.get("MIRU_SERVER_HOST") or "api.mirurobotics.com" self.host = host if version is None: diff --git a/src/miru_server_sdk/resources/config_instances.py b/src/miru_server_sdk/resources/config_instances.py index f27ddd9..525e890 100644 --- a/src/miru_server_sdk/resources/config_instances.py +++ b/src/miru_server_sdk/resources/config_instances.py @@ -32,7 +32,7 @@ def with_raw_response(self) -> ConfigInstancesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return ConfigInstancesResourceWithRawResponse(self) @@ -41,7 +41,7 @@ def with_streaming_response(self) -> ConfigInstancesResourceWithStreamingRespons """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return ConfigInstancesResourceWithStreamingResponse(self) @@ -175,7 +175,7 @@ def with_raw_response(self) -> AsyncConfigInstancesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return AsyncConfigInstancesResourceWithRawResponse(self) @@ -184,7 +184,7 @@ def with_streaming_response(self) -> AsyncConfigInstancesResourceWithStreamingRe """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return AsyncConfigInstancesResourceWithStreamingResponse(self) diff --git a/src/miru_server_sdk/resources/deployments.py b/src/miru_server_sdk/resources/deployments.py index 729563b..8e8e2b3 100644 --- a/src/miru_server_sdk/resources/deployments.py +++ b/src/miru_server_sdk/resources/deployments.py @@ -38,7 +38,7 @@ def with_raw_response(self) -> DeploymentsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return DeploymentsResourceWithRawResponse(self) @@ -47,7 +47,7 @@ def with_streaming_response(self) -> DeploymentsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return DeploymentsResourceWithStreamingResponse(self) @@ -307,7 +307,7 @@ def with_raw_response(self) -> AsyncDeploymentsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return AsyncDeploymentsResourceWithRawResponse(self) @@ -316,7 +316,7 @@ def with_streaming_response(self) -> AsyncDeploymentsResourceWithStreamingRespon """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return AsyncDeploymentsResourceWithStreamingResponse(self) diff --git a/src/miru_server_sdk/resources/devices.py b/src/miru_server_sdk/resources/devices.py index 32d9cf9..8eb46c5 100644 --- a/src/miru_server_sdk/resources/devices.py +++ b/src/miru_server_sdk/resources/devices.py @@ -39,7 +39,7 @@ def with_raw_response(self) -> DevicesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return DevicesResourceWithRawResponse(self) @@ -48,7 +48,7 @@ def with_streaming_response(self) -> DevicesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return DevicesResourceWithStreamingResponse(self) @@ -310,7 +310,7 @@ def with_raw_response(self) -> AsyncDevicesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return AsyncDevicesResourceWithRawResponse(self) @@ -319,7 +319,7 @@ def with_streaming_response(self) -> AsyncDevicesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return AsyncDevicesResourceWithStreamingResponse(self) diff --git a/src/miru_server_sdk/resources/releases.py b/src/miru_server_sdk/resources/releases.py index a945f5f..57ccdd6 100644 --- a/src/miru_server_sdk/resources/releases.py +++ b/src/miru_server_sdk/resources/releases.py @@ -32,7 +32,7 @@ def with_raw_response(self) -> ReleasesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return ReleasesResourceWithRawResponse(self) @@ -41,7 +41,7 @@ def with_streaming_response(self) -> ReleasesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return ReleasesResourceWithStreamingResponse(self) @@ -155,7 +155,7 @@ def with_raw_response(self) -> AsyncReleasesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/miruml/python-server-sdk#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/mirurobotics/python-server-sdk#accessing-raw-response-data-eg-headers """ return AsyncReleasesResourceWithRawResponse(self) @@ -164,7 +164,7 @@ def with_streaming_response(self) -> AsyncReleasesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/miruml/python-server-sdk#with_streaming_response + For more information, see https://www.github.com/mirurobotics/python-server-sdk#with_streaming_response """ return AsyncReleasesResourceWithStreamingResponse(self) diff --git a/tests/test_client.py b/tests/test_client.py index 3780af6..400c34b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -572,7 +572,7 @@ def test_base_url_env(self) -> None: Miru(api_key=api_key, _strict_response_validation=True, environment="prod") client = Miru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") - assert str(client.base_url).startswith("https://configs.api.miruml.com/beta") + assert str(client.base_url).startswith("https://api.mirurobotics.com/beta") client.close() @@ -1405,7 +1405,7 @@ async def test_base_url_env(self) -> None: AsyncMiru(api_key=api_key, _strict_response_validation=True, environment="prod") client = AsyncMiru(base_url=None, api_key=api_key, _strict_response_validation=True, environment="prod") - assert str(client.base_url).startswith("https://configs.api.miruml.com/beta") + assert str(client.base_url).startswith("https://api.mirurobotics.com/beta") await client.close() From 18e2399c16b063cec9dfce45aa040ef770354fcd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:36:46 +0000 Subject: [PATCH 22/22] release: 0.7.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/miru_server_sdk/_version.py | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ac03171..1b77f50 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.1" + ".": "0.7.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a9c86..45af9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.7.0 (2026-01-08) + +Full Changelog: [v0.6.1...v0.7.0](https://github.com/mirurobotics/python-server-sdk/compare/v0.6.1...v0.7.0) + +### ⚠ BREAKING CHANGES + +* **api:** change route to be /beta instead of /v1 + +### Bug Fixes + +* **client:** close streams without requiring full consumption ([c50bca7](https://github.com/mirurobotics/python-server-sdk/commit/c50bca723dc9398bf97d9db9d881f8090b9deef0)) +* compat with Python 3.14 ([6e10abe](https://github.com/mirurobotics/python-server-sdk/commit/6e10abeb80e20e1d64eb9e1d251bd2d426ed62ad)) +* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([b71ebf9](https://github.com/mirurobotics/python-server-sdk/commit/b71ebf96149950711b88780da4dce30a1e250e02)) +* ensure streams are always closed ([ab93bca](https://github.com/mirurobotics/python-server-sdk/commit/ab93bca51d143a6e8d26a05947e538d15ee751ea)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([7769f80](https://github.com/mirurobotics/python-server-sdk/commit/7769f807b07bc2d6b611766e1e5d4545289d558d)) +* use async_to_httpx_files in patch method ([4c68967](https://github.com/mirurobotics/python-server-sdk/commit/4c6896709773ce6fee0641f107ab0c7364f9716d)) + + +### Chores + +* add missing docstrings ([37f1eae](https://github.com/mirurobotics/python-server-sdk/commit/37f1eae5abbe16ad18e05bdf8bfa47509304c7ee)) +* add Python 3.14 classifier and testing ([6df3ecd](https://github.com/mirurobotics/python-server-sdk/commit/6df3ecd00b28abb16c06cae6c13b8728445ace6a)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([d644966](https://github.com/mirurobotics/python-server-sdk/commit/d644966063e8b3e17ca407ad4062be2874e07878)) +* **docs:** use environment variables for authentication in code snippets ([ee97b11](https://github.com/mirurobotics/python-server-sdk/commit/ee97b11addd6b183e49a28d6ccbc7cb73b9247dd)) +* **internal/tests:** avoid race condition with implicit client cleanup ([445023e](https://github.com/mirurobotics/python-server-sdk/commit/445023e773a89e857f47d1ca9fb61f0c1f6fde7b)) +* **internal:** add `--fix` argument to lint script ([22cd02f](https://github.com/mirurobotics/python-server-sdk/commit/22cd02f5f184a085b365aca7c1f1f6959be0fbcb)) +* **internal:** add missing files argument to base client ([1d7820f](https://github.com/mirurobotics/python-server-sdk/commit/1d7820f56b688e75988288e86b2a6eb62657681f)) +* **internal:** grammar fix (it's -> its) ([5c82d3a](https://github.com/mirurobotics/python-server-sdk/commit/5c82d3a7b9f90620c68e850441cbd204d475e8d0)) +* **package:** drop Python 3.8 support ([80b7c35](https://github.com/mirurobotics/python-server-sdk/commit/80b7c35b8dbe72ecf05cca08217b934eb8bef3a5)) +* speedup initial import ([4ebe231](https://github.com/mirurobotics/python-server-sdk/commit/4ebe2315fe940766e2888b4cda52c7d9799e6132)) +* update lockfile ([f840597](https://github.com/mirurobotics/python-server-sdk/commit/f8405973872ef23e5294cf061d7794497e15a1ec)) + + +### Refactors + +* **api:** api domain from miruml to mirurobotics ([c4127dd](https://github.com/mirurobotics/python-server-sdk/commit/c4127dd3a6e14e10757ea6f29929370231c1ceaf)) +* **api:** change route to be /beta instead of /v1 ([6b7bd87](https://github.com/mirurobotics/python-server-sdk/commit/6b7bd87fd125f8ac6205af2d289e75036df693e4)) + ## 0.6.1 (2025-10-21) Full Changelog: [v0.6.0...v0.6.1](https://github.com/miruml/python-server-sdk/compare/v0.6.0...v0.6.1) diff --git a/pyproject.toml b/pyproject.toml index 0c3af8c..cae8015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miru_server_sdk" -version = "0.6.1" +version = "0.7.0" description = "The official Python library for the miru API" dynamic = ["readme"] license = "MIT" diff --git a/src/miru_server_sdk/_version.py b/src/miru_server_sdk/_version.py index 0c10485..030e971 100644 --- a/src/miru_server_sdk/_version.py +++ b/src/miru_server_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "miru_server_sdk" -__version__ = "0.6.1" # x-release-please-version +__version__ = "0.7.0" # x-release-please-version