diff --git a/.gitignore b/.gitignore index 877974080..95ceb189a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .prism.log -.vscode _dev __pycache__ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 26b1ce24e..2b2b4fa9e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.50.0" + ".": "0.51.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 39fbbd07f..1e72a9a13 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 92 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-963f8c488e668763300b08860aef9dd85b72c32069ca20e0c36795e86d2938b1.yml -openapi_spec_hash: 65c50f6ab92fd7bf7d108be411923a7a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-bbb5c731b774122662526cc0b24015828012b1f09bb32fe2d54498aa09e52c92.yml +openapi_spec_hash: 3d23e89561895724da8d48967dd26f17 config_hash: 60681f589a9e641fdb7f19af2021a033 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..5b0103078 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/CHANGELOG.md b/CHANGELOG.md index aeda677af..21920e4fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.51.0 (2025-07-29) + +Full Changelog: [v0.50.0...v0.51.0](https://github.com/runloopai/api-client-python/compare/v0.50.0...v0.51.0) + +### Features + +* **api:** api update ([5d42629](https://github.com/runloopai/api-client-python/commit/5d426295ac258ede767c131a209b1deab4d746a6)) + + +### Bug Fixes + +* **parsing:** ignore empty metadata ([10c81bd](https://github.com/runloopai/api-client-python/commit/10c81bd7e10ee82e1414aede07c6889630322f6b)) +* **parsing:** parse extra field types ([9d2a5f0](https://github.com/runloopai/api-client-python/commit/9d2a5f01e3646d127dec75dca085c2168c35f48a)) + + +### Chores + +* **project:** add settings file for vscode ([7a11210](https://github.com/runloopai/api-client-python/commit/7a1121066a7eeb7ca3acb9185834154556d52894)) +* **types:** rebuild Pydantic models after all types are defined ([b1839c6](https://github.com/runloopai/api-client-python/commit/b1839c61f42496896ed58b90a983771902f2c26e)) + ## 0.50.0 (2025-07-15) Full Changelog: [v0.49.0...v0.50.0](https://github.com/runloopai/api-client-python/compare/v0.49.0...v0.50.0) diff --git a/pyproject.toml b/pyproject.toml index 23b22004e..bc46a146d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "0.50.0" +version = "0.51.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/src/runloop_api_client/_models.py b/src/runloop_api_client/_models.py index 528d56803..b8387ce98 100644 --- a/src/runloop_api_client/_models.py +++ b/src/runloop_api_client/_models.py @@ -208,14 +208,18 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] else: fields_values[name] = field_get_default(field) + extra_field_type = _get_extra_fields_type(__cls) + _extra = {} for key, value in values.items(): if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + if PYDANTIC_V2: - _extra[key] = value + _extra[key] = parsed else: _fields_set.add(key) - fields_values[key] = value + fields_values[key] = parsed object.__setattr__(m, "__dict__", fields_values) @@ -370,6 +374,23 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if not PYDANTIC_V2: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + def is_basemodel(type_: type) -> bool: """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" if is_union(type_): @@ -439,7 +460,7 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if metadata is not None: + if metadata is not None and len(metadata) > 0: meta: tuple[Any, ...] = tuple(metadata) elif is_annotated_type(type_): meta = get_args(type_)[1:] diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index ab9750372..fd4eb3432 100644 --- a/src/runloop_api_client/_version.py +++ b/src/runloop_api_client/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "runloop_api_client" -__version__ = "0.50.0" # x-release-please-version +__version__ = "0.51.0" # x-release-please-version diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index f5b04281c..2a3bcbafe 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -1305,7 +1305,9 @@ def upload_file( id: str, *, path: str, + chmod: Optional[str] | NotGiven = NOT_GIVEN, file: FileTypes | NotGiven = NOT_GIVEN, + owner: Optional[str] | 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, @@ -1324,6 +1326,12 @@ def upload_file( path: The path to write the file to on the Devbox. Path is relative to user home directory. + chmod: File permissions in octal format (e.g., "644", "1755"). Optional. If not + specified, default system permissions will be used. + + owner: File owner username. Optional. If not specified, the file will be owned by the + current user. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -1341,7 +1349,9 @@ def upload_file( body = deepcopy_minimal( { "path": path, + "chmod": chmod, "file": file, + "owner": owner, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) @@ -1369,6 +1379,8 @@ def write_file_contents( *, contents: str, file_path: str, + chmod: Optional[str] | NotGiven = NOT_GIVEN, + owner: Optional[str] | 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, @@ -1388,6 +1400,12 @@ def write_file_contents( file_path: The path to write the file to on the Devbox. Path is relative to user home directory. + chmod: File permissions in octal format (e.g., "644", "1755"). Optional. If not + specified, default system permissions will be used. + + owner: File owner username. Optional. If not specified, the file will be owned by the + current user. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -1408,6 +1426,8 @@ def write_file_contents( { "contents": contents, "file_path": file_path, + "chmod": chmod, + "owner": owner, }, devbox_write_file_contents_params.DevboxWriteFileContentsParams, ), @@ -2598,7 +2618,9 @@ async def upload_file( id: str, *, path: str, + chmod: Optional[str] | NotGiven = NOT_GIVEN, file: FileTypes | NotGiven = NOT_GIVEN, + owner: Optional[str] | 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, @@ -2617,6 +2639,12 @@ async def upload_file( path: The path to write the file to on the Devbox. Path is relative to user home directory. + chmod: File permissions in octal format (e.g., "644", "1755"). Optional. If not + specified, default system permissions will be used. + + owner: File owner username. Optional. If not specified, the file will be owned by the + current user. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -2634,7 +2662,9 @@ async def upload_file( body = deepcopy_minimal( { "path": path, + "chmod": chmod, "file": file, + "owner": owner, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) @@ -2662,6 +2692,8 @@ async def write_file_contents( *, contents: str, file_path: str, + chmod: Optional[str] | NotGiven = NOT_GIVEN, + owner: Optional[str] | 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, @@ -2681,6 +2713,12 @@ async def write_file_contents( file_path: The path to write the file to on the Devbox. Path is relative to user home directory. + chmod: File permissions in octal format (e.g., "644", "1755"). Optional. If not + specified, default system permissions will be used. + + owner: File owner username. Optional. If not specified, the file will be owned by the + current user. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -2701,6 +2739,8 @@ async def write_file_contents( { "contents": contents, "file_path": file_path, + "chmod": chmod, + "owner": owner, }, devbox_write_file_contents_params.DevboxWriteFileContentsParams, ), diff --git a/src/runloop_api_client/types/__init__.py b/src/runloop_api_client/types/__init__.py index e21c31852..5a4773839 100644 --- a/src/runloop_api_client/types/__init__.py +++ b/src/runloop_api_client/types/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from . import devboxes +from .. import _compat from .shared import ( AfterIdle as AfterIdle, LaunchParameters as LaunchParameters, @@ -81,3 +83,14 @@ from .devbox_write_file_contents_params import DevboxWriteFileContentsParams as DevboxWriteFileContentsParams from .devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView as DevboxAsyncExecutionDetailView from .devbox_read_file_contents_response import DevboxReadFileContentsResponse as DevboxReadFileContentsResponse + +# Rebuild cyclical models only after all modules are imported. +# This ensures that, when building the deferred (due to cyclical references) model schema, +# Pydantic can resolve the necessary references. +# See: https://github.com/pydantic/pydantic/issues/11250 for more context. +if _compat.PYDANTIC_V2: + devboxes.code_segment_info_response.CodeSegmentInfoResponse.model_rebuild(_parent_namespace_depth=0) + devboxes.document_symbol.DocumentSymbol.model_rebuild(_parent_namespace_depth=0) +else: + devboxes.code_segment_info_response.CodeSegmentInfoResponse.update_forward_refs() # type: ignore + devboxes.document_symbol.DocumentSymbol.update_forward_refs() # type: ignore diff --git a/src/runloop_api_client/types/devbox_upload_file_params.py b/src/runloop_api_client/types/devbox_upload_file_params.py index 9e83fa318..edcad111d 100644 --- a/src/runloop_api_client/types/devbox_upload_file_params.py +++ b/src/runloop_api_client/types/devbox_upload_file_params.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Optional from typing_extensions import Required, TypedDict from .._types import FileTypes @@ -16,4 +17,16 @@ class DevboxUploadFileParams(TypedDict, total=False): Path is relative to user home directory. """ + chmod: Optional[str] + """File permissions in octal format (e.g., "644", "1755"). + + Optional. If not specified, default system permissions will be used. + """ + file: FileTypes + + owner: Optional[str] + """File owner username. + + Optional. If not specified, the file will be owned by the current user. + """ diff --git a/src/runloop_api_client/types/devbox_write_file_contents_params.py b/src/runloop_api_client/types/devbox_write_file_contents_params.py index f47a73839..549125912 100644 --- a/src/runloop_api_client/types/devbox_write_file_contents_params.py +++ b/src/runloop_api_client/types/devbox_write_file_contents_params.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Optional from typing_extensions import Required, TypedDict __all__ = ["DevboxWriteFileContentsParams"] @@ -16,3 +17,15 @@ class DevboxWriteFileContentsParams(TypedDict, total=False): Path is relative to user home directory. """ + + chmod: Optional[str] + """File permissions in octal format (e.g., "644", "1755"). + + Optional. If not specified, default system permissions will be used. + """ + + owner: Optional[str] + """File owner username. + + Optional. If not specified, the file will be owned by the current user. + """ diff --git a/src/runloop_api_client/types/devboxes/code_segment_info_response.py b/src/runloop_api_client/types/devboxes/code_segment_info_response.py index aa0641872..0e931bd28 100644 --- a/src/runloop_api_client/types/devboxes/code_segment_info_response.py +++ b/src/runloop_api_client/types/devboxes/code_segment_info_response.py @@ -5,7 +5,6 @@ from typing import List, Optional from .file_uri import FileUri -from ..._compat import PYDANTIC_V2 from ..._models import BaseModel from .base_range import BaseRange from .base_location import BaseLocation @@ -45,10 +44,3 @@ class CodeSegmentInfoResponse(BaseModel): from .document_symbol import DocumentSymbol - -if PYDANTIC_V2: - CodeSegmentInfoResponse.model_rebuild() - Hover.model_rebuild() -else: - CodeSegmentInfoResponse.update_forward_refs() # type: ignore - Hover.update_forward_refs() # type: ignore diff --git a/src/runloop_api_client/types/devboxes/document_symbol.py b/src/runloop_api_client/types/devboxes/document_symbol.py index 30ca96d6d..39eb3e0ce 100644 --- a/src/runloop_api_client/types/devboxes/document_symbol.py +++ b/src/runloop_api_client/types/devboxes/document_symbol.py @@ -2,12 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from pydantic import Field as FieldInfo from .range import Range -from ..._compat import PYDANTIC_V2 from ..._models import BaseModel from .symbol_tag import SymbolTag from .symbol_kind import SymbolKind @@ -51,14 +50,9 @@ class DocumentSymbol(BaseModel): tags: Optional[List[SymbolTag]] = None """Tags for this document symbol.""" + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. # `getattr(obj, '$type')` def __getattr__(self, attr: str) -> object: ... - - -if PYDANTIC_V2: - DocumentSymbol.model_rebuild() -else: - DocumentSymbol.update_forward_refs() # type: ignore diff --git a/src/runloop_api_client/types/devboxes/position.py b/src/runloop_api_client/types/devboxes/position.py index 411f291c1..25937dec2 100644 --- a/src/runloop_api_client/types/devboxes/position.py +++ b/src/runloop_api_client/types/devboxes/position.py @@ -1,6 +1,8 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict + +from pydantic import Field as FieldInfo from .uinteger import Uinteger from ..._models import BaseModel @@ -27,6 +29,7 @@ class Position(BaseModel): defaults to 0. """ + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. diff --git a/src/runloop_api_client/types/devboxes/range.py b/src/runloop_api_client/types/devboxes/range.py index 9be6adb56..b7b4717e5 100644 --- a/src/runloop_api_client/types/devboxes/range.py +++ b/src/runloop_api_client/types/devboxes/range.py @@ -1,6 +1,8 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict + +from pydantic import Field as FieldInfo from .position import Position from ..._models import BaseModel @@ -15,6 +17,7 @@ class Range(BaseModel): start: Position """The range's start position.""" + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. diff --git a/src/runloop_api_client/types/devboxes/text_edit.py b/src/runloop_api_client/types/devboxes/text_edit.py index 30826edbc..3f1c860a8 100644 --- a/src/runloop_api_client/types/devboxes/text_edit.py +++ b/src/runloop_api_client/types/devboxes/text_edit.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from pydantic import Field as FieldInfo @@ -20,6 +20,7 @@ class TextEdit(BaseModel): To insert text into a document create a range where start === end. """ + __pydantic_extra__: Dict[str, object] = FieldInfo(init=False) # pyright: ignore[reportIncompatibleVariableOverride] if TYPE_CHECKING: # Stub to indicate that arbitrary properties are accepted. # To access properties that are not valid identifiers you can use `getattr`, e.g. diff --git a/src/runloop_api_client/types/repository_manifest_view.py b/src/runloop_api_client/types/repository_manifest_view.py index d4dbf0b3e..334910a99 100644 --- a/src/runloop_api_client/types/repository_manifest_view.py +++ b/src/runloop_api_client/types/repository_manifest_view.py @@ -1,10 +1,17 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional +from typing import Dict, List, Optional from .._models import BaseModel -__all__ = ["RepositoryManifestView", "ContainerConfig", "Workspace", "WorkspaceDevCommands"] +__all__ = [ + "RepositoryManifestView", + "ContainerConfig", + "Workspace", + "WorkspaceDevCommands", + "ContainerizedService", + "ContainerizedServiceCredentials", +] class ContainerConfig(BaseModel): @@ -23,21 +30,21 @@ class ContainerConfig(BaseModel): class WorkspaceDevCommands(BaseModel): - build: Optional[str] = None + build: Optional[List[str]] = None """Build command (e.g. npm run build).""" - install: Optional[str] = None + install: Optional[List[str]] = None """Installation command (e.g. pip install -r requirements.txt).""" - lint: Optional[str] = None + lint: Optional[List[str]] = None """Lint command (e.g. flake8).""" - test: Optional[str] = None + test: Optional[List[str]] = None """Test command (e.g. pytest).""" class Workspace(BaseModel): - package_manager: str + package_manager: List[str] """Name of the package manager used (e.g. pip, npm).""" dev_commands: Optional[WorkspaceDevCommands] = None @@ -80,6 +87,37 @@ class Workspace(BaseModel): """ +class ContainerizedServiceCredentials(BaseModel): + password: str + """The password of the container service.""" + + username: str + """The username of the container service.""" + + +class ContainerizedService(BaseModel): + image: str + """The image of the container service.""" + + name: str + """The name of the container service.""" + + credentials: Optional[ContainerizedServiceCredentials] = None + """The credentials of the container service.""" + + env: Optional[Dict[str, str]] = None + """The environment variables of the container service.""" + + options: Optional[str] = None + """Additional Docker container create options.""" + + port_mappings: Optional[List[str]] = None + """The port mappings of the container service. + + Port mappings are in the format of :. + """ + + class RepositoryManifestView(BaseModel): container_config: ContainerConfig """Container configuration specifying the base image and setup commands.""" @@ -89,3 +127,9 @@ class RepositoryManifestView(BaseModel): Each workspace represents a buildable unit of code. """ + + containerized_services: Optional[List[ContainerizedService]] = None + """List of discovered ContainerizedServices. + + Services can be explicitly started when creating a Devbox. + """ diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py index e2b3c9fea..0cf7d8b2c 100644 --- a/tests/api_resources/test_devboxes.py +++ b/tests/api_resources/test_devboxes.py @@ -886,7 +886,9 @@ def test_method_upload_file_with_all_params(self, client: Runloop) -> None: devbox = client.devboxes.upload_file( id="id", path="path", + chmod="chmod", file=b"raw file contents", + owner="owner", ) assert_matches_type(object, devbox, path=["response"]) @@ -933,6 +935,17 @@ def test_method_write_file_contents(self, client: Runloop) -> None: ) assert_matches_type(DevboxExecutionDetailView, devbox, path=["response"]) + @parametrize + def test_method_write_file_contents_with_all_params(self, client: Runloop) -> None: + devbox = client.devboxes.write_file_contents( + id="id", + contents="contents", + file_path="file_path", + chmod="chmod", + owner="owner", + ) + assert_matches_type(DevboxExecutionDetailView, devbox, path=["response"]) + @parametrize def test_raw_response_write_file_contents(self, client: Runloop) -> None: response = client.devboxes.with_raw_response.write_file_contents( @@ -2097,7 +2110,9 @@ async def test_method_upload_file_with_all_params(self, async_client: AsyncRunlo devbox = await async_client.devboxes.upload_file( id="id", path="path", + chmod="chmod", file=b"raw file contents", + owner="owner", ) assert_matches_type(object, devbox, path=["response"]) @@ -2144,6 +2159,17 @@ async def test_method_write_file_contents(self, async_client: AsyncRunloop) -> N ) assert_matches_type(DevboxExecutionDetailView, devbox, path=["response"]) + @parametrize + async def test_method_write_file_contents_with_all_params(self, async_client: AsyncRunloop) -> None: + devbox = await async_client.devboxes.write_file_contents( + id="id", + contents="contents", + file_path="file_path", + chmod="chmod", + owner="owner", + ) + assert_matches_type(DevboxExecutionDetailView, devbox, path=["response"]) + @parametrize async def test_raw_response_write_file_contents(self, async_client: AsyncRunloop) -> None: response = await async_client.devboxes.with_raw_response.write_file_contents( diff --git a/tests/test_models.py b/tests/test_models.py index 519462f8b..b11d9b63d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal, Annotated, TypeAliasType @@ -934,3 +934,30 @@ class Type2(BaseModel): ) assert isinstance(model, Type1) assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo"