Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
**Bug fixes:**

- fix(dictionary): Correct example dictionary name to use valid characters
- fix(model): Make `ModelSimple` behave like a sequence when `value` is a list/tuple so generated response models (e.g. `DomainsResponse`) can be iterated, indexed and sized.
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new changelog entry is indented with an extra leading space ( - fix(model): ...), which will render as a nested list item in Markdown. Align it with the other bullets under Bug fixes: (no leading space before -).

Suggested change
- fix(model): Make `ModelSimple` behave like a sequence when `value` is a list/tuple so generated response models (e.g. `DomainsResponse`) can be iterated, indexed and sized.
- fix(model): Make `ModelSimple` behave like a sequence when `value` is a list/tuple so generated response models (e.g. `DomainsResponse`) can be iterated, indexed and sized.

Copilot uses AI. Check for mistakes.

**Enhancements:**

Expand Down
60 changes: 60 additions & 0 deletions fastly/model_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,31 @@ def get(self, name, default=None):

def __getitem__(self, name):
"""get the value of an attribute using square-bracket notation: `instance[attr]`"""
# Support numeric indexing for simple models that wrap a list-like
# value (for example response models that have `value: [Item]`).
# Iteration (for x in model) falls back to __getitem__ with integer
# indices if __iter__ is not present, so accept ints and slices here.
if isinstance(name, (int, slice)):
try:
v = self.__dict__['_data_store'].get('value')
except Exception:
v = None
if v is None:
raise ApiAttributeError(
"{0} has no attribute '{1}'".format(
type(self).__name__, name),
[e for e in [self._path_to_item, name] if e]
)
Comment on lines +475 to +479
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new int/slice error path construction, the list comprehension uses if e, which will drop index 0 (because 0 is falsy). That can lose useful context for callers when model[0] fails. Consider filtering with is not None (or similar) so index 0 is preserved in the path.

Copilot uses AI. Check for mistakes.
try:
return v[name]
except Exception:
# normalize to attribute error expected by callers
raise ApiAttributeError(
"{0} has no attribute '{1}'".format(
type(self).__name__, name),
[e for e in [self._path_to_item, name] if e]
)

Comment on lines +480 to +489
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__getitem__ returns v[name] for int/slice names without verifying that value is actually a list/tuple. This is inconsistent with __iter__/__len__, which treat non-(list, tuple) value as a single item; e.g., len(model)==1 but model[0] can still raise. Consider limiting numeric indexing to list/tuple values, or explicitly handling index 0/slices for the single-item case so the sequence semantics stay consistent.

Suggested change
try:
return v[name]
except Exception:
# normalize to attribute error expected by callers
raise ApiAttributeError(
"{0} has no attribute '{1}'".format(
type(self).__name__, name),
[e for e in [self._path_to_item, name] if e]
)
if isinstance(v, (list, tuple)):
try:
return v[name]
except Exception:
# normalize to attribute error expected by callers
raise ApiAttributeError(
"{0} has no attribute '{1}'".format(
type(self).__name__, name),
[e for e in [self._path_to_item, name] if e]
)
if isinstance(name, slice):
return [v][name]
if name in (0, -1):
return v
raise ApiAttributeError(
"{0} has no attribute '{1}'".format(
type(self).__name__, name),
[e for e in [self._path_to_item, name] if e]
)

Copilot uses AI. Check for mistakes.
if name in self:
return self.get(name)

Expand All @@ -471,6 +496,41 @@ def __getitem__(self, name):
[e for e in [self._path_to_item, name] if e]
)

def __iter__(self):
"""Allow iteration over simple models that wrap a sequence in `value`.

Example: for item in DomainsResponse(...): iterates over the underlying list.
"""
try:
v = self.__dict__['_data_store'].get('value')
except Exception:
v = None
if v is None:
# behave like an empty iterator
return iter(())
# Only treat list/tuple as a true sequence to iterate over. Dicts are
# technically iterable (yield keys) but in API models the `value`
# field may contain a dict representing a single item. Treat non
# (list, tuple) values as single items.
if isinstance(v, (list, tuple)):
return iter(v)
# Otherwise wrap single value in an iterator
return iter((v,))

def __len__(self):
"""Return length of the underlying sequence if present, otherwise 0."""
try:
v = self.__dict__['_data_store'].get('value')
except Exception:
Comment on lines +520 to +524
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding __len__ changes truthiness for ModelSimple instances (Python will use __len__ for bool(model) when __bool__ is not defined). For list-based responses this means empty responses become falsy, which can be a backward-incompatible behavioral change. If that’s not intended, consider defining __bool__ to preserve prior truthiness, or documenting this as a behavior change.

Copilot uses AI. Check for mistakes.
return 0
if v is None:
return 0
# If underlying value is a sequence (list/tuple), return its length.
if isinstance(v, (list, tuple)):
return len(v)
# For dict or other single objects, treat as single item.
return 1
Comment on lines +528 to +532
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The __len__ docstring says “otherwise 0”, but the implementation returns 1 for non-(list, tuple) values (treating them as a single item). Update the docstring to match the actual behavior.

Copilot uses AI. Check for mistakes.

def __contains__(self, name):
"""used by `in` operator to check if an attribute value was set in an instance: `'attr' in instance`"""
if name in self.required_properties:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_model_simple_sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest is imported but not used in this test file. Removing the unused import keeps the test module clean and avoids unused-import lint noise (if/when linting is enabled).

Suggested change
import pytest

Copilot uses AI. Check for mistakes.
from fastly.model.domains_response import DomainsResponse


def test_domains_response_iteration_and_indexing():
data = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}]
d = DomainsResponse(data, _check_type=False)

# len
assert len(d) == 3

# list conversion
assert list(d) == data

# indexing
assert d[0] == data[0]
assert d[1] == data[1]

# slicing
assert d[1:3] == data[1:3]

# iteration via for
out = []
for it in d:
out.append(it)
assert out == data


def test_domains_response_empty_and_single():
# empty
d_empty = DomainsResponse([], _check_type=False)
assert len(d_empty) == 0
assert list(d_empty) == []

# single non-sequence value should be iterable as single item
d_single = DomainsResponse({'k': 'v'}, _check_type=False)
assert len(d_single) == 1
assert list(d_single) == [{'k': 'v'}]
Loading