diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f7014c35..a7130553 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.0" + ".": "0.12.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index e93723a0..b0ef81ab 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 193 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/gitpod%2Fgitpod-3dcdbd68ce4b336149d28d17ab08f211538ed6630112ae4883af2f6680643159.yml -openapi_spec_hash: 7e4333995b65cf32663166801e2444bb -config_hash: 8d7b241284195a8c51f5d670fbbe0ab4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/gitpod%2Fgitpod-0e20f536f7f833dd7205eaadd72826bd527a3ed91fcb006323e3232550abb04e.yml +openapi_spec_hash: 7a96aca1a75735a1664edd091b277940 +config_hash: 25c7c72de891ed240ee62c4fb9a99756 diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc444e4..90e1410f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.12.0 (2026-04-17) + +Full Changelog: [v0.11.0...v0.12.0](https://github.com/gitpod-io/gitpod-sdk-python/compare/v0.11.0...v0.12.0) + +### Features + +* **api:** add integration_id field, make webhook_id required in pull_request trigger ([077b662](https://github.com/gitpod-io/gitpod-sdk-python/commit/077b6622dc4e21a2033c8275c46716286a6515b8)) +* **api:** add old_path field to ContentGitChangedFile ([d79d4d4](https://github.com/gitpod-io/gitpod-sdk-python/commit/d79d4d49ef41a889a81a0d484616fca442849356)) +* **api:** add port_authentication capability to runner_capability ([c29b095](https://github.com/gitpod-io/gitpod-sdk-python/commit/c29b09596d2d87caaea047e61613a333c2fe4e31)) +* **api:** add prebuild trigger value to environments automations ([af2c44e](https://github.com/gitpod-io/gitpod-sdk-python/commit/af2c44e64fac19bf848c4325e0b39b183c998e74)) +* **api:** add PULL_REQUEST_EVENT_REVIEW_REQUESTED to workflow_trigger events ([242a3ab](https://github.com/gitpod-io/gitpod-sdk-python/commit/242a3ab60ed3580fd9488858727294ed86568ccf)) +* **api:** add SUPPORTED_MODEL_OPUS_4_7 to agent_execution Status ([74af533](https://github.com/gitpod-io/gitpod-sdk-python/commit/74af5338d7f6447df5a6464a2b2ef893c3bf4f6e)) +* **api:** remove terminal field from RunsOn type ([faca2b2](https://github.com/gitpod-io/gitpod-sdk-python/commit/faca2b27b88f17a759bd91c305e5ea2a856a1e2c)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([b7f0b1d](https://github.com/gitpod-io/gitpod-sdk-python/commit/b7f0b1d27ef51872bf81541dd7f81a8101f856af)) +* ensure file data are only sent as 1 parameter ([5c02854](https://github.com/gitpod-io/gitpod-sdk-python/commit/5c02854efdc2874535d3d7867823048d5e8d4693)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([cb792b6](https://github.com/gitpod-io/gitpod-sdk-python/commit/cb792b633104ff26eb799d8c32703920213ec23e)) + + +### Documentation + +* **api:** update trigger usage note in AutomationTrigger ([5a292cb](https://github.com/gitpod-io/gitpod-sdk-python/commit/5a292cb1ef87a0dd73d93445707412d25c0e95e0)) + ## 0.11.0 (2026-04-02) Full Changelog: [v0.10.0...v0.11.0](https://github.com/gitpod-io/gitpod-sdk-python/compare/v0.10.0...v0.11.0) diff --git a/pyproject.toml b/pyproject.toml index 5d1671eb..0528316d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gitpod-sdk" -version = "0.11.0" +version = "0.12.0" description = "The official Python library for the gitpod API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/gitpod/_base_client.py b/src/gitpod/_base_client.py index cc7f8af2..a9da58f9 100644 --- a/src/gitpod/_base_client.py +++ b/src/gitpod/_base_client.py @@ -540,6 +540,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/src/gitpod/_files.py b/src/gitpod/_files.py index cc14c14f..0fdce17b 100644 --- a/src/gitpod/_files.py +++ b/src/gitpod/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/gitpod/_utils/__init__.py b/src/gitpod/_utils/__init__.py index 10cb66d2..1c090e51 100644 --- a/src/gitpod/_utils/__init__.py +++ b/src/gitpod/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/gitpod/_utils/_utils.py b/src/gitpod/_utils/_utils.py index eec7f4a1..771859f5 100644 --- a/src/gitpod/_utils/_utils.py +++ b/src/gitpod/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] @@ -176,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/gitpod/_version.py b/src/gitpod/_version.py index 7504df3f..6521e93a 100644 --- a/src/gitpod/_version.py +++ b/src/gitpod/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "gitpod" -__version__ = "0.11.0" # x-release-please-version +__version__ = "0.12.0" # x-release-please-version diff --git a/src/gitpod/types/agent_execution.py b/src/gitpod/types/agent_execution.py index 07f02eda..02be3ac1 100644 --- a/src/gitpod/types/agent_execution.py +++ b/src/gitpod/types/agent_execution.py @@ -455,6 +455,7 @@ class Status(BaseModel): "SUPPORTED_MODEL_OPUS_4_5_EXTENDED", "SUPPORTED_MODEL_OPUS_4_6", "SUPPORTED_MODEL_OPUS_4_6_EXTENDED", + "SUPPORTED_MODEL_OPUS_4_7", "SUPPORTED_MODEL_HAIKU_4_5", "SUPPORTED_MODEL_OPENAI_4O", "SUPPORTED_MODEL_OPENAI_4O_MINI", diff --git a/src/gitpod/types/environment_status.py b/src/gitpod/types/environment_status.py index 45ad12ca..d5ed5c65 100644 --- a/src/gitpod/types/environment_status.py +++ b/src/gitpod/types/environment_status.py @@ -91,6 +91,12 @@ class ContentGitChangedFile(BaseModel): ] = FieldInfo(alias="changeType", default=None) """ChangeType is the type of change that happened to the file""" + old_path: Optional[str] = FieldInfo(alias="oldPath", default=None) + """ + old_path is the previous path of the file before a rename or copy. Only set when + change_type is RENAMED or COPIED. + """ + path: Optional[str] = None """path is the path of the file""" diff --git a/src/gitpod/types/environments/automations_file_param.py b/src/gitpod/types/environments/automations_file_param.py index 226782ce..68deffa5 100644 --- a/src/gitpod/types/environments/automations_file_param.py +++ b/src/gitpod/types/environments/automations_file_param.py @@ -54,7 +54,8 @@ class Services(TypedDict, total=False): runs_on: Annotated[RunsOn, PropertyInfo(alias="runsOn")] triggered_by: Annotated[ - List[Literal["manual", "postEnvironmentStart", "postDevcontainerStart"]], PropertyInfo(alias="triggeredBy") + List[Literal["manual", "postEnvironmentStart", "postDevcontainerStart", "prebuild"]], + PropertyInfo(alias="triggeredBy"), ] diff --git a/src/gitpod/types/runner_capability.py b/src/gitpod/types/runner_capability.py index c2e50b46..2e473ac1 100644 --- a/src/gitpod/types/runner_capability.py +++ b/src/gitpod/types/runner_capability.py @@ -18,4 +18,5 @@ "RUNNER_CAPABILITY_RUNNER_SIDE_AGENT", "RUNNER_CAPABILITY_WARM_POOL", "RUNNER_CAPABILITY_ASG_WARM_POOL", + "RUNNER_CAPABILITY_PORT_AUTHENTICATION", ] diff --git a/src/gitpod/types/shared/automation_trigger.py b/src/gitpod/types/shared/automation_trigger.py index 8ffd732b..3d1fafb8 100644 --- a/src/gitpod/types/shared/automation_trigger.py +++ b/src/gitpod/types/shared/automation_trigger.py @@ -20,7 +20,7 @@ class AutomationTrigger(BaseModel): The `prebuild` field starts the automation during a prebuild of an environment. This phase does not have user secrets available. The `before_snapshot` field triggers the automation after all prebuild tasks complete but before the snapshot is taken. This is used for tasks that need to run last during prebuilds, such as IDE warmup. - Note: The prebuild and before_snapshot triggers can only be used with tasks, not services. + Note: The before_snapshot trigger can only be used with tasks, not services. """ before_snapshot: Optional[bool] = FieldInfo(alias="beforeSnapshot", default=None) diff --git a/src/gitpod/types/shared/runs_on.py b/src/gitpod/types/shared/runs_on.py index 5f26ef47..33cf1bd5 100644 --- a/src/gitpod/types/shared/runs_on.py +++ b/src/gitpod/types/shared/runs_on.py @@ -18,9 +18,3 @@ class RunsOn(BaseModel): machine: Optional[object] = None """Machine runs the service/task directly on the VM/machine level.""" - - terminal: Optional[object] = None - """ - Terminal runs the service inside a managed PTY terminal in the devcontainer. - Users can attach to the terminal interactively via the terminal API. - """ diff --git a/src/gitpod/types/shared_params/automation_trigger.py b/src/gitpod/types/shared_params/automation_trigger.py index 27dc462b..1020f005 100644 --- a/src/gitpod/types/shared_params/automation_trigger.py +++ b/src/gitpod/types/shared_params/automation_trigger.py @@ -20,7 +20,7 @@ class AutomationTrigger(TypedDict, total=False): The `prebuild` field starts the automation during a prebuild of an environment. This phase does not have user secrets available. The `before_snapshot` field triggers the automation after all prebuild tasks complete but before the snapshot is taken. This is used for tasks that need to run last during prebuilds, such as IDE warmup. - Note: The prebuild and before_snapshot triggers can only be used with tasks, not services. + Note: The before_snapshot trigger can only be used with tasks, not services. """ before_snapshot: Annotated[bool, PropertyInfo(alias="beforeSnapshot")] diff --git a/src/gitpod/types/shared_params/runs_on.py b/src/gitpod/types/shared_params/runs_on.py index 0182710a..8fc0fd3c 100644 --- a/src/gitpod/types/shared_params/runs_on.py +++ b/src/gitpod/types/shared_params/runs_on.py @@ -20,9 +20,3 @@ class RunsOn(TypedDict, total=False): machine: object """Machine runs the service/task directly on the VM/machine level.""" - - terminal: object - """ - Terminal runs the service inside a managed PTY terminal in the devcontainer. - Users can attach to the terminal interactively via the terminal API. - """ diff --git a/src/gitpod/types/workflow_trigger.py b/src/gitpod/types/workflow_trigger.py index 4b044761..cefa42fc 100644 --- a/src/gitpod/types/workflow_trigger.py +++ b/src/gitpod/types/workflow_trigger.py @@ -27,10 +27,18 @@ class PullRequest(BaseModel): "PULL_REQUEST_EVENT_MERGED", "PULL_REQUEST_EVENT_CLOSED", "PULL_REQUEST_EVENT_READY_FOR_REVIEW", + "PULL_REQUEST_EVENT_REVIEW_REQUESTED", ] ] ] = None + integration_id: Optional[str] = FieldInfo(alias="integrationId", default=None) + """ + integration_id is the optional ID of an integration that acts as the source of + webhook events. When set, the trigger will be activated when the webhook + receives events. + """ + webhook_id: Optional[str] = FieldInfo(alias="webhookId", default=None) """ webhook_id is the optional ID of a webhook that this trigger is bound to. When diff --git a/src/gitpod/types/workflow_trigger_param.py b/src/gitpod/types/workflow_trigger_param.py index ed28c9f8..ee1ef3c1 100644 --- a/src/gitpod/types/workflow_trigger_param.py +++ b/src/gitpod/types/workflow_trigger_param.py @@ -26,9 +26,17 @@ class PullRequest(TypedDict, total=False): "PULL_REQUEST_EVENT_MERGED", "PULL_REQUEST_EVENT_CLOSED", "PULL_REQUEST_EVENT_READY_FOR_REVIEW", + "PULL_REQUEST_EVENT_REVIEW_REQUESTED", ] ] + integration_id: Annotated[Optional[str], PropertyInfo(alias="integrationId")] + """ + integration_id is the optional ID of an integration that acts as the source of + webhook events. When set, the trigger will be activated when the webhook + receives events. + """ + webhook_id: Annotated[Optional[str], PropertyInfo(alias="webhookId")] """ webhook_id is the optional ID of a webhook that this trigger is bound to. When diff --git a/tests/api_resources/environments/automations/test_services.py b/tests/api_resources/environments/automations/test_services.py index 3a80cc01..3d1ccf73 100644 --- a/tests/api_resources/environments/automations/test_services.py +++ b/tests/api_resources/environments/automations/test_services.py @@ -75,7 +75,6 @@ def test_method_create_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, "session": "session", "spec_version": "specVersion", @@ -188,7 +187,6 @@ def test_method_update_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, }, status={ @@ -437,7 +435,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, "session": "session", "spec_version": "specVersion", @@ -550,7 +547,6 @@ async def test_method_update_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, }, status={ diff --git a/tests/api_resources/environments/automations/test_tasks.py b/tests/api_resources/environments/automations/test_tasks.py index 29599ec1..242dc25e 100644 --- a/tests/api_resources/environments/automations/test_tasks.py +++ b/tests/api_resources/environments/automations/test_tasks.py @@ -71,7 +71,6 @@ def test_method_create_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, }, ) @@ -178,7 +177,6 @@ def test_method_update_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, }, ) @@ -377,7 +375,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, }, ) @@ -484,7 +481,6 @@ async def test_method_update_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, }, ) diff --git a/tests/api_resources/environments/test_automations.py b/tests/api_resources/environments/test_automations.py index bed1aa5a..15f3fe3a 100644 --- a/tests/api_resources/environments/test_automations.py +++ b/tests/api_resources/environments/test_automations.py @@ -44,7 +44,6 @@ def test_method_upsert_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, "triggered_by": ["postDevcontainerStart"], } @@ -61,7 +60,6 @@ def test_method_upsert_with_all_params(self, client: Gitpod) -> None: "image": "x", }, "machine": {}, - "terminal": {}, }, "triggered_by": ["postEnvironmentStart"], } @@ -126,7 +124,6 @@ async def test_method_upsert_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, "triggered_by": ["postDevcontainerStart"], } @@ -143,7 +140,6 @@ async def test_method_upsert_with_all_params(self, async_client: AsyncGitpod) -> "image": "x", }, "machine": {}, - "terminal": {}, }, "triggered_by": ["postEnvironmentStart"], } diff --git a/tests/api_resources/test_automations.py b/tests/api_resources/test_automations.py index f1412921..943c0d0a 100644 --- a/tests/api_resources/test_automations.py +++ b/tests/api_resources/test_automations.py @@ -113,6 +113,7 @@ def test_method_create_with_all_params(self, client: Gitpod) -> None: "manual": {}, "pull_request": { "events": ["PULL_REQUEST_EVENT_UNSPECIFIED"], + "integration_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", "webhook_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", }, "time": {"cron_expression": "cronExpression"}, @@ -256,6 +257,7 @@ def test_method_update_with_all_params(self, client: Gitpod) -> None: "manual": {}, "pull_request": { "events": ["PULL_REQUEST_EVENT_UNSPECIFIED"], + "integration_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", "webhook_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", }, "time": {"cron_expression": "cronExpression"}, @@ -793,6 +795,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncGitpod) -> "manual": {}, "pull_request": { "events": ["PULL_REQUEST_EVENT_UNSPECIFIED"], + "integration_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", "webhook_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", }, "time": {"cron_expression": "cronExpression"}, @@ -936,6 +939,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncGitpod) -> "manual": {}, "pull_request": { "events": ["PULL_REQUEST_EVENT_UNSPECIFIED"], + "integration_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", "webhook_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", }, "time": {"cron_expression": "cronExpression"}, diff --git a/tests/test_client.py b/tests/test_client.py index 92b0eea3..c105b4d9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -438,6 +438,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: Gitpod) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Gitpod) -> None: request = client._build_request( FinalRequestOptions( @@ -1363,6 +1387,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncGitpod) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Gitpod) -> None: request = client._build_request( FinalRequestOptions( diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index c498f531..00000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from gitpod._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 0ca5a8dc..a9d8b167 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ diff --git a/tests/test_files.py b/tests/test_files.py index efde0d4e..2e09e8a4 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from gitpod._files import to_httpx_files, async_to_httpx_files +from gitpod._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from gitpod._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + }