diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fea34540..2601677b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "1.1.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 6a34fdb2..0221da5d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 6 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-d5cf52f21333b8216b73e9659b4a1e8e0675404f0ae3d15bdd7ef368ccfa94cf.yml -openapi_spec_hash: c70cbc2e38e7aeaf2173574a13e9ca55 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c921d60adf854da13dbb83d547cbd8a32fd86d625fb12a325b7d305da7f3a93a.yml +openapi_spec_hash: c02b88f26faaf9fd04177b77d34fd5c3 config_hash: 372b187172495fc2f76f05ba016b4a45 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f98f00a..15e4a3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.1.0 (2025-06-03) + +Full Changelog: [v1.0.0...v1.1.0](https://github.com/brand-dot-dev/python-sdk/compare/v1.0.0...v1.1.0) + +### Features + +* **api:** manual updates ([fa44a02](https://github.com/brand-dot-dev/python-sdk/commit/fa44a02f3397ae35b14ce65f8cacb60b4cc76b33)) +* **client:** add follow_redirects request option ([7a9a565](https://github.com/brand-dot-dev/python-sdk/commit/7a9a5654ef7f84e4b8231ec5c50b972dc2241dba)) + + +### Chores + +* **docs:** remove reference to rye shell ([5d1533b](https://github.com/brand-dot-dev/python-sdk/commit/5d1533ba7fd39e6d1b0cc4cd44793e4de4233d64)) +* **docs:** remove unnecessary param examples ([93f2f3f](https://github.com/brand-dot-dev/python-sdk/commit/93f2f3f95c7ebaa648a16c752d4d81c48929c39c)) +* **internal:** version bump ([7a15a0a](https://github.com/brand-dot-dev/python-sdk/commit/7a15a0aceaca0cd700f7aab8282e5bdef8cbf31f)) + ## 1.0.0 (2025-06-02) Full Changelog: [v0.0.1-alpha.1...v1.0.0](https://github.com/brand-dot-dev/python-sdk/compare/v0.0.1-alpha.1...v1.0.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6164c91c..81265296 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix diff --git a/README.md b/README.md index 4f528a5f..81f47348 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,30 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from brand.dev import BrandDev + +client = BrandDev() + +response = client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + specific_pages={}, +) +print(response.specific_pages) +``` + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `brand.dev.APIConnectionError` is raised. diff --git a/pyproject.toml b/pyproject.toml index 4755f1a1..a9d04968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.0.0" +version = "1.1.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index 0f72d317..ce093416 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -18,7 +18,7 @@ UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install --pre 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 67fcdf8c..35eb16c0 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -960,6 +960,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1460,6 +1463,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 798956f1..4f214980 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index 7231eff3..382635bb 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +216,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 0b3fad12..655680dc 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.0.0" # x-release-please-version +__version__ = "1.1.0" # x-release-please-version diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 45424156..95ecfa09 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Iterable +from typing import Iterable from typing_extensions import Literal import httpx @@ -161,7 +161,7 @@ def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: List[str] | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -180,7 +180,7 @@ def ai_query( domain: The domain name to analyze - specific_pages: Optional array of specific pages to analyze + specific_pages: Optional object specifying which pages to analyze extra_headers: Send extra headers @@ -487,7 +487,7 @@ async def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: List[str] | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -506,7 +506,7 @@ async def ai_query( domain: The domain name to analyze - specific_pages: Optional array of specific pages to analyze + specific_pages: Optional object specifying which pages to analyze extra_headers: Send extra headers diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py index f65e5696..d199778d 100644 --- a/src/brand/dev/types/brand_ai_query_params.py +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import List, Iterable +from typing import Iterable from typing_extensions import Literal, Required, TypedDict -__all__ = ["BrandAIQueryParams", "DataToExtract"] +__all__ = ["BrandAIQueryParams", "DataToExtract", "SpecificPages"] class BrandAIQueryParams(TypedDict, total=False): @@ -15,8 +15,8 @@ class BrandAIQueryParams(TypedDict, total=False): domain: Required[str] """The domain name to analyze""" - specific_pages: List[str] - """Optional array of specific pages to analyze""" + specific_pages: SpecificPages + """Optional object specifying which pages to analyze""" class DataToExtract(TypedDict, total=False): @@ -31,3 +31,29 @@ class DataToExtract(TypedDict, total=False): datapoint_type: Required[Literal["text", "number", "date", "boolean", "list", "url"]] """Type of the data point""" + + +class SpecificPages(TypedDict, total=False): + about_us: bool + """Whether to analyze the about us page""" + + blog: bool + """Whether to analyze the blog""" + + careers: bool + """Whether to analyze the careers page""" + + contact_us: bool + """Whether to analyze the contact us page""" + + faq: bool + """Whether to analyze the FAQ page""" + + home_page: bool + """Whether to analyze the home page""" + + privacy_policy: bool + """Whether to analyze the privacy policy page""" + + terms_and_conditions: bool + """Whether to analyze the terms and conditions page""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 3ff2fdd3..3f1629e3 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -96,7 +96,16 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: } ], domain="domain", - specific_pages=["string"], + specific_pages={ + "about_us": True, + "blog": True, + "careers": True, + "contact_us": True, + "faq": True, + "home_page": True, + "privacy_policy": True, + "terms_and_conditions": True, + }, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) @@ -354,7 +363,16 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev } ], domain="domain", - specific_pages=["string"], + specific_pages={ + "about_us": True, + "blog": True, + "careers": True, + "contact_us": True, + "faq": True, + "home_page": True, + "privacy_policy": True, + "terms_and_conditions": True, + }, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index 6cc808bf..391b339b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -814,6 +814,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> 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) + 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: + # 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 + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncBrandDev: client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1635,3 +1662,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> 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) + 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: + # 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( + "/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"