wandb 0.19.8__py3-none-macosx_11_0_x86_64.whl → 0.19.9__py3-none-macosx_11_0_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. wandb/__init__.py +5 -1
  2. wandb/__init__.pyi +12 -8
  3. wandb/_pydantic/__init__.py +23 -0
  4. wandb/_pydantic/base.py +113 -0
  5. wandb/_pydantic/v1_compat.py +262 -0
  6. wandb/apis/paginator.py +82 -38
  7. wandb/apis/public/api.py +10 -64
  8. wandb/apis/public/artifacts.py +73 -17
  9. wandb/apis/public/files.py +2 -2
  10. wandb/apis/public/projects.py +3 -2
  11. wandb/apis/public/reports.py +2 -2
  12. wandb/apis/public/runs.py +19 -11
  13. wandb/bin/gpu_stats +0 -0
  14. wandb/bin/wandb-core +0 -0
  15. wandb/integration/metaflow/metaflow.py +19 -17
  16. wandb/integration/sacred/__init__.py +1 -1
  17. wandb/jupyter.py +18 -15
  18. wandb/proto/v3/wandb_internal_pb2.py +7 -3
  19. wandb/proto/v3/wandb_settings_pb2.py +2 -2
  20. wandb/proto/v3/wandb_telemetry_pb2.py +4 -4
  21. wandb/proto/v4/wandb_internal_pb2.py +3 -3
  22. wandb/proto/v4/wandb_settings_pb2.py +2 -2
  23. wandb/proto/v4/wandb_telemetry_pb2.py +4 -4
  24. wandb/proto/v5/wandb_internal_pb2.py +3 -3
  25. wandb/proto/v5/wandb_settings_pb2.py +2 -2
  26. wandb/proto/v5/wandb_telemetry_pb2.py +4 -4
  27. wandb/proto/wandb_deprecated.py +2 -0
  28. wandb/sdk/artifacts/_graphql_fragments.py +18 -20
  29. wandb/sdk/artifacts/_validators.py +1 -0
  30. wandb/sdk/artifacts/artifact.py +70 -36
  31. wandb/sdk/artifacts/artifact_saver.py +16 -2
  32. wandb/sdk/artifacts/storage_policies/wandb_storage_policy.py +23 -2
  33. wandb/sdk/data_types/audio.py +1 -3
  34. wandb/sdk/data_types/base_types/media.py +11 -4
  35. wandb/sdk/data_types/image.py +44 -25
  36. wandb/sdk/data_types/molecule.py +1 -5
  37. wandb/sdk/data_types/object_3d.py +2 -1
  38. wandb/sdk/data_types/saved_model.py +7 -9
  39. wandb/sdk/data_types/video.py +1 -4
  40. wandb/{apis/public → sdk/internal}/_generated/__init__.py +0 -6
  41. wandb/sdk/internal/_generated/base.py +226 -0
  42. wandb/{apis/public → sdk/internal}/_generated/server_features_query.py +3 -3
  43. wandb/{apis/public → sdk/internal}/_generated/typing_compat.py +1 -1
  44. wandb/sdk/internal/internal_api.py +138 -47
  45. wandb/sdk/internal/sender.py +2 -0
  46. wandb/sdk/internal/sender_config.py +8 -11
  47. wandb/sdk/internal/settings_static.py +24 -2
  48. wandb/sdk/lib/apikey.py +15 -16
  49. wandb/sdk/lib/run_moment.py +4 -6
  50. wandb/sdk/lib/wb_logging.py +161 -0
  51. wandb/sdk/wandb_config.py +44 -43
  52. wandb/sdk/wandb_init.py +141 -79
  53. wandb/sdk/wandb_metadata.py +107 -91
  54. wandb/sdk/wandb_run.py +152 -44
  55. wandb/sdk/wandb_settings.py +403 -201
  56. wandb/sdk/wandb_setup.py +3 -1
  57. {wandb-0.19.8.dist-info → wandb-0.19.9.dist-info}/METADATA +3 -3
  58. {wandb-0.19.8.dist-info → wandb-0.19.9.dist-info}/RECORD +64 -60
  59. wandb/apis/public/_generated/base.py +0 -128
  60. /wandb/{apis/public → sdk/internal}/_generated/enums.py +0 -0
  61. /wandb/{apis/public → sdk/internal}/_generated/input_types.py +0 -0
  62. /wandb/{apis/public → sdk/internal}/_generated/operations.py +0 -0
  63. {wandb-0.19.8.dist-info → wandb-0.19.9.dist-info}/WHEEL +0 -0
  64. {wandb-0.19.8.dist-info → wandb-0.19.9.dist-info}/entry_points.txt +0 -0
  65. {wandb-0.19.8.dist-info → wandb-0.19.9.dist-info}/licenses/LICENSE +0 -0
wandb/__init__.py CHANGED
@@ -10,7 +10,7 @@ For reference documentation, see https://docs.wandb.com/ref/python.
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
- __version__ = "0.19.8"
13
+ __version__ = "0.19.9"
14
14
 
15
15
 
16
16
  from wandb.errors import Error
@@ -18,6 +18,10 @@ from wandb.errors import Error
18
18
  # This needs to be early as other modules call it.
19
19
  from wandb.errors.term import termsetup, termlog, termerror, termwarn
20
20
 
21
+ # Configure the logger as early as possible for consistent behavior.
22
+ from wandb.sdk.lib import wb_logging as _wb_logging
23
+ _wb_logging.configure_wandb_logger()
24
+
21
25
  from wandb import sdk as wandb_sdk
22
26
 
23
27
  import wandb
wandb/__init__.pyi CHANGED
@@ -106,7 +106,7 @@ if TYPE_CHECKING:
106
106
  import wandb
107
107
  from wandb.plot import CustomChart
108
108
 
109
- __version__: str = "0.19.8"
109
+ __version__: str = "0.19.9"
110
110
 
111
111
  run: Run | None
112
112
  config: wandb_config.Config
@@ -222,7 +222,15 @@ def init(
222
222
  mode: Literal["online", "offline", "disabled"] | None = None,
223
223
  force: bool | None = None,
224
224
  anonymous: Literal["never", "allow", "must"] | None = None,
225
- reinit: bool | None = None,
225
+ reinit: (
226
+ bool
227
+ | Literal[
228
+ None,
229
+ "default",
230
+ "return_previous",
231
+ "finish_previous",
232
+ ]
233
+ ) = None,
226
234
  resume: bool | Literal["allow", "never", "must", "auto"] | None = None,
227
235
  resume_from: str | None = None,
228
236
  fork_from: str | None = None,
@@ -370,12 +378,8 @@ def init(
370
378
  to view the charts and data in the UI.
371
379
  - `"must"`: Forces the run to be logged to an anonymous account, even
372
380
  if the user is logged in.
373
- reinit: Determines if multiple `wandb.init()` calls can start new runs
374
- within the same process. By default (`False`), if an active run
375
- exists, calling `wandb.init()` returns the existing run instead of
376
- creating a new one. When `reinit=True`, the active run is finished
377
- before a new run is initialized. In notebook environments, runs are
378
- reinitialized by default unless `reinit` is explicitly set to `False`.
381
+ reinit: Shorthand for the "reinit" setting. Determines the behavior of
382
+ `wandb.init()` when a run is active.
379
383
  resume: Controls the behavior when resuming a run with the specified `id`.
380
384
  Available options are:
381
385
  - `"allow"`: If a run with the specified `id` exists, it will resume
@@ -0,0 +1,23 @@
1
+ """Internal utilities for working with pydantic."""
2
+
3
+ from .base import Base, GQLBase, GQLId, SerializedToJson, Typename
4
+ from .v1_compat import (
5
+ IS_PYDANTIC_V2,
6
+ AliasChoices,
7
+ computed_field,
8
+ field_validator,
9
+ model_validator,
10
+ )
11
+
12
+ __all__ = [
13
+ "IS_PYDANTIC_V2",
14
+ "Base",
15
+ "GQLBase",
16
+ "Typename",
17
+ "GQLId",
18
+ "SerializedToJson",
19
+ "AliasChoices",
20
+ "computed_field",
21
+ "field_validator",
22
+ "model_validator",
23
+ ]
@@ -0,0 +1,113 @@
1
+ """Base classes and other customizations for generated pydantic types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, Json
8
+ from typing_extensions import Annotated, TypedDict, Unpack, override
9
+
10
+ from .v1_compat import PydanticCompatMixin
11
+
12
+ if TYPE_CHECKING:
13
+ from pydantic.main import IncEx
14
+
15
+
16
+ class ModelDumpKwargs(TypedDict, total=False):
17
+ """Shared keyword arguments for `BaseModel.model_{dump,dump_json}`."""
18
+
19
+ include: IncEx | None
20
+ exclude: IncEx | None
21
+ context: dict[str, Any] | None
22
+ by_alias: bool | None
23
+ exclude_unset: bool
24
+ exclude_defaults: bool
25
+ exclude_none: bool
26
+ round_trip: bool
27
+ warnings: bool | Literal["none", "warn", "error"]
28
+ fallback: Callable[[Any], Any] | None
29
+ serialize_as_any: bool
30
+
31
+
32
+ #: Custom overrides of default kwargs for `BaseModel.model_{dump,dump_json}`.
33
+ MODEL_DUMP_DEFAULTS = ModelDumpKwargs(
34
+ by_alias=True, # Always serialize with aliases (e.g. camelCase names)
35
+ round_trip=True, # Ensure serialized values remain valid inputs for deserialization
36
+ )
37
+
38
+
39
+ # Base class for all generated classes/types.
40
+ # Omitted from docstring to avoid inclusion in generated docs.
41
+ class Base(PydanticCompatMixin, BaseModel):
42
+ model_config = ConfigDict(
43
+ populate_by_name=True,
44
+ validate_assignment=True,
45
+ validate_default=True,
46
+ extra="forbid",
47
+ use_attribute_docstrings=True,
48
+ from_attributes=True,
49
+ revalidate_instances="always",
50
+ )
51
+
52
+ @override
53
+ def model_dump(
54
+ self,
55
+ *,
56
+ mode: Literal["json", "python"] | str = "json", # NOTE: changed default
57
+ **kwargs: Unpack[ModelDumpKwargs],
58
+ ) -> dict[str, Any]:
59
+ kwargs = {**MODEL_DUMP_DEFAULTS, **kwargs}
60
+ return super().model_dump(mode=mode, **kwargs)
61
+
62
+ @override
63
+ def model_dump_json(
64
+ self,
65
+ *,
66
+ indent: int | None = None,
67
+ **kwargs: Unpack[ModelDumpKwargs],
68
+ ) -> str:
69
+ kwargs = {**MODEL_DUMP_DEFAULTS, **kwargs}
70
+ return super().model_dump_json(indent=indent, **kwargs)
71
+
72
+
73
+ # Base class with extra customization for GQL generated types.
74
+ # Omitted from docstring to avoid inclusion in generated docs.
75
+ class GQLBase(Base):
76
+ model_config = ConfigDict(
77
+ extra="ignore",
78
+ protected_namespaces=(),
79
+ )
80
+
81
+
82
+ # ------------------------------------------------------------------------------
83
+ # Reusable annotations for field types
84
+ T = TypeVar("T")
85
+
86
+ GQLId = Annotated[
87
+ str,
88
+ Field(repr=False, strict=True, frozen=True),
89
+ ]
90
+
91
+ Typename = Annotated[
92
+ T,
93
+ Field(repr=False, alias="__typename", frozen=True),
94
+ ]
95
+
96
+
97
+ # FIXME: Restore, modify, or replace this later after ensuring pydantic v1 compatibility.
98
+ # def validate_maybe_json(v: Any, handler: ValidatorFunctionWrapHandler) -> Any:
99
+ # """Wraps default Json[...] field validator to allow instantiation with an already-decoded value."""
100
+ # try:
101
+ # return handler(v)
102
+ # except ValidationError:
103
+ # # Try revalidating after properly jsonifying the value
104
+ # return handler(to_json(v, by_alias=True, round_trip=True))
105
+ #
106
+ #
107
+ # SerializedToJson = Annotated[
108
+ # Json[T],
109
+ # # Allow lenient instantiation/validation: incoming data may already be deserialized.
110
+ # WrapValidator(validate_maybe_json),
111
+ # ]
112
+
113
+ SerializedToJson = Json[T]
@@ -0,0 +1,262 @@
1
+ """Provides partial support for compatibility with Pydantic v1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib.metadata import version
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ Any,
10
+ Callable,
11
+ ClassVar,
12
+ Literal,
13
+ Mapping,
14
+ TypeVar,
15
+ overload,
16
+ )
17
+
18
+ import pydantic
19
+ from typing_extensions import ParamSpec
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Protocol
23
+
24
+ class V1Model(Protocol):
25
+ __config__: ClassVar[type]
26
+ __fields__: ClassVar[dict[str, Any]]
27
+ __fields_set__: set[str]
28
+
29
+ @classmethod
30
+ def update_forward_refs(cls, *args: Any, **kwargs: Any) -> None: ...
31
+ @classmethod
32
+ def construct(cls, *args: Any, **kwargs: Any) -> V1Model: ...
33
+ @classmethod
34
+ def parse_obj(cls, *args: Any, **kwargs: Any) -> V1Model: ...
35
+ @classmethod
36
+ def parse_raw(cls, *args: Any, **kwargs: Any) -> V1Model: ...
37
+ def dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: ...
38
+ def json(self, *args: Any, **kwargs: Any) -> str: ...
39
+ def copy(self, *args: Any, **kwargs: Any) -> V1Model: ...
40
+
41
+
42
+ PYTHON_VERSION = sys.version_info
43
+
44
+ pydantic_major_version, *_ = version(pydantic.__name__).split(".")
45
+ IS_PYDANTIC_V2: bool = int(pydantic_major_version) >= 2
46
+
47
+
48
+ ModelT = TypeVar("ModelT")
49
+ RT = TypeVar("RT")
50
+ P = ParamSpec("P")
51
+
52
+
53
+ # Maps {v2 -> v1} model config keys that were renamed in v2.
54
+ # See: https://docs.pydantic.dev/latest/migration/#changes-to-config
55
+ _V1_CONFIG_KEYS = {
56
+ "populate_by_name": "allow_population_by_field_name",
57
+ "str_to_lower": "anystr_lower",
58
+ "str_strip_whitespace": "anystr_strip_whitespace",
59
+ "str_to_upper": "anystr_upper",
60
+ "ignored_types": "keep_untouched",
61
+ "str_max_length": "max_anystr_length",
62
+ "str_min_length": "min_anystr_length",
63
+ "from_attributes": "orm_mode",
64
+ "json_schema_extra": "schema_extra",
65
+ "validate_default": "validate_all",
66
+ }
67
+
68
+
69
+ def _convert_v2_config(v2_config: dict[str, Any]) -> dict[str, Any]:
70
+ """Return a copy of the v2 ConfigDict with renamed v1 keys."""
71
+ return {_V1_CONFIG_KEYS.get(k, k): v for k, v in v2_config.items()}
72
+
73
+
74
+ # Pydantic BaseModels are defined with a custom metaclass, but its namespace
75
+ # has changed between pydantic versions.
76
+ #
77
+ # In v1, it can be imported as `from pydantic.main import ModelMetaclass`
78
+ # In v2, it's defined in an internal module so we avoid directly importing it.
79
+ PydanticModelMetaclass: type = type(pydantic.BaseModel)
80
+
81
+
82
+ class V1MixinMetaclass(PydanticModelMetaclass):
83
+ def __new__(
84
+ cls,
85
+ name: str,
86
+ bases: tuple[type, ...],
87
+ namespace: dict[str, Any],
88
+ **kwargs: Any,
89
+ ):
90
+ # Converts a `model_config` dict in a V2 class definition, e.g.:
91
+ #
92
+ # class MyModel(BaseModel):
93
+ # model_config = ConfigDict(populate_by_name=True)
94
+ #
95
+ # ...to a `Config` class in a V1 class definition, e.g.:
96
+ #
97
+ # class MyModel(BaseModel):
98
+ # class Config:
99
+ # allow_population_by_field_name = True
100
+ #
101
+ if config_dict := namespace.pop("model_config", None):
102
+ namespace["Config"] = type("Config", (), _convert_v2_config(config_dict))
103
+ return super().__new__(cls, name, bases, namespace, **kwargs)
104
+
105
+ # note: workarounds to patch "class properties" aren't consistent between python
106
+ # versions, so this will have to do until changes are needed.
107
+ if not ((3, 9) <= PYTHON_VERSION < (3, 13)):
108
+
109
+ @property
110
+ def model_fields(self) -> dict[str, Any]:
111
+ return self.__fields__
112
+
113
+
114
+ # Mixin to ensure compatibility of Pydantic models if Pydantic v1 is detected.
115
+ # These are "best effort" implementations and cannot guarantee complete
116
+ # compatibility in v1 environments.
117
+ #
118
+ # Whenever possible, users should strongly prefer upgrading to Pydantic v2 to
119
+ # ensure full compatibility.
120
+ class V1Mixin(metaclass=V1MixinMetaclass):
121
+ @classmethod
122
+ def __try_update_forward_refs__(cls: type[V1Model], **localns: Any) -> None:
123
+ if hasattr(sup := super(), "__try_update_forward_refs__"):
124
+ sup.__try_update_forward_refs__(**localns)
125
+
126
+ @classmethod
127
+ def model_rebuild(cls, *args: Any, **kwargs: Any) -> None:
128
+ return cls.update_forward_refs(*args, **kwargs)
129
+
130
+ @classmethod
131
+ def model_construct(cls, *args: Any, **kwargs: Any) -> V1Model:
132
+ return cls.construct(*args, **kwargs)
133
+
134
+ @classmethod
135
+ def model_validate(cls, *args: Any, **kwargs: Any) -> V1Model:
136
+ return cls.parse_obj(*args, **kwargs)
137
+
138
+ @classmethod
139
+ def model_validate_json(cls, *args: Any, **kwargs: Any) -> V1Model:
140
+ return cls.parse_raw(*args, **kwargs)
141
+
142
+ def model_dump(self: V1Model, *args: Any, **kwargs: Any) -> dict[str, Any]:
143
+ return self.dict(*args, **kwargs)
144
+
145
+ def model_dump_json(self: V1Model, *args: Any, **kwargs: Any) -> str:
146
+ return self.json(*args, **kwargs)
147
+
148
+ def model_copy(self: V1Model, *args: Any, **kwargs: Any) -> V1Model:
149
+ return self.copy(*args, **kwargs)
150
+
151
+ # workarounds to patch "class properties" aren't consistent between python
152
+ # versions, so this will have to do until changes are needed.
153
+ if (3, 9) <= PYTHON_VERSION < (3, 13):
154
+
155
+ @classmethod # type: ignore[misc]
156
+ @property
157
+ def model_fields(cls: type[V1Model]) -> Mapping[str, Any]:
158
+ return cls.__fields__
159
+
160
+ @property
161
+ def model_fields_set(self: V1Model) -> set[str]:
162
+ return self.__fields_set__
163
+
164
+
165
+ # Placeholder. Pydantic v2 is already compatible with itself, so no need for extra mixins.
166
+ class V2Mixin:
167
+ pass
168
+
169
+
170
+ # Pick the mixin type based on the detected Pydantic version.
171
+ PydanticCompatMixin: type = V2Mixin if IS_PYDANTIC_V2 else V1Mixin
172
+
173
+
174
+ # ----------------------------------------------------------------------------
175
+ # Decorators and other pydantic helpers
176
+ # ----------------------------------------------------------------------------
177
+ if IS_PYDANTIC_V2:
178
+ field_validator = pydantic.field_validator
179
+ model_validator = pydantic.model_validator
180
+ AliasChoices = pydantic.AliasChoices
181
+ computed_field = pydantic.computed_field
182
+
183
+ else:
184
+ # Redefines `@field_validator` with a v2-like signature
185
+ # to call `@validator` from v1 instead.
186
+ def field_validator(
187
+ field: str,
188
+ /,
189
+ *fields: str,
190
+ mode: Literal["before", "after", "wrap", "plain"] = "after",
191
+ check_fields: bool | None = None,
192
+ **_: Any,
193
+ ) -> Callable:
194
+ return pydantic.validator(
195
+ field,
196
+ *fields,
197
+ pre=(mode == "before"),
198
+ always=True,
199
+ check_fields=bool(check_fields),
200
+ allow_reuse=True,
201
+ )
202
+
203
+ # Redefines `@model_validator` with a v2-like signature
204
+ # to call `@root_validator` from v1 instead.
205
+ def model_validator(
206
+ *,
207
+ mode: Literal["before", "after", "wrap", "plain"],
208
+ **_: Any,
209
+ ) -> Callable:
210
+ if mode == "after":
211
+ # Patch the behavior for `@model_validator(mode="after")` in v1. This is
212
+ # necessarily complicated because:
213
+ # - `@model_validator(mode="after")` decorates an instance method in pydantic v2
214
+ # - `@root_validator(pre=False)` always decorates a classmethod in pydantic v1
215
+ def _decorator(v2_method: Callable) -> Any:
216
+ def v1_method(
217
+ cls: type[V1Model], values: dict[str, Any]
218
+ ) -> dict[str, Any]:
219
+ # Note: Since this is an "after" validator, the values should already be
220
+ # validated, so `.construct()` in v1 (`.model_construct()` in v2)
221
+ # should create a valid object to pass to the **original** decorated instance method.
222
+ validated = v2_method(cls.construct(**values))
223
+
224
+ # Pydantic v1 expects the validator to return a dict of {field_name -> value}
225
+ return {
226
+ name: getattr(validated, name) for name in validated.__fields__
227
+ }
228
+
229
+ return pydantic.root_validator(pre=False, allow_reuse=True)( # type: ignore[call-overload]
230
+ classmethod(v1_method)
231
+ )
232
+
233
+ return _decorator
234
+ else:
235
+ return pydantic.root_validator(pre=(mode == "before"), allow_reuse=True) # type: ignore[call-overload]
236
+
237
+ @overload # type: ignore[no-redef]
238
+ def computed_field(func: Callable | property, /) -> property: ...
239
+ @overload
240
+ def computed_field(
241
+ func: None, /, **_: Any
242
+ ) -> Callable[[Callable | property], property]: ...
243
+
244
+ def computed_field(
245
+ func: Callable | property | None = None, /, **_: Any
246
+ ) -> property | Callable[[Callable | property], property]:
247
+ """Compatibility wrapper for Pydantic v2's `computed_field` in v1."""
248
+
249
+ def always_property(f: Callable | property) -> property:
250
+ # Convert the method to a property only if needed
251
+ return f if isinstance(f, property) else property(f)
252
+
253
+ # Handle both decorator styles
254
+ return always_property if (func is None) else always_property(func)
255
+
256
+ class AliasChoices: # type: ignore [no-redef]
257
+ """Placeholder class for Pydantic v2's AliasChoices for partial v1 compatibility."""
258
+
259
+ aliases: list[str]
260
+
261
+ def __init__(self, *aliases: str):
262
+ self.aliases = list(aliases)
wandb/apis/paginator.py CHANGED
@@ -1,75 +1,103 @@
1
- from typing import TYPE_CHECKING, Any, MutableMapping, Optional
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from typing import (
5
+ TYPE_CHECKING,
6
+ Any,
7
+ ClassVar,
8
+ Iterator,
9
+ Mapping,
10
+ Protocol,
11
+ Sized,
12
+ TypeVar,
13
+ overload,
14
+ )
2
15
 
3
16
  if TYPE_CHECKING:
4
- from wandb_gql import Client
17
+ from wandb_graphql.language.ast import Document
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ # Structural type hint for the client instance
23
+ class _Client(Protocol):
24
+ def execute(self, *args: Any, **kwargs: Any) -> dict[str, Any]: ...
25
+
5
26
 
27
+ class Paginator(Iterator[T]):
28
+ """An iterator for paginated objects from GraphQL requests."""
6
29
 
7
- class Paginator:
8
- QUERY = None
30
+ QUERY: ClassVar[Document | None] = None
9
31
 
10
32
  def __init__(
11
33
  self,
12
- client: "Client",
13
- variables: MutableMapping[str, Any],
14
- per_page: Optional[int] = None,
34
+ client: _Client,
35
+ variables: Mapping[str, Any],
36
+ per_page: int = 50, # We don't allow unbounded paging
15
37
  ):
16
- self.client = client
17
- self.variables = variables
18
- # We don't allow unbounded paging
19
- self.per_page = per_page
20
- if self.per_page is None:
21
- self.per_page = 50
22
- self.objects = []
23
- self.index = -1
24
- self.last_response = None
38
+ self.client: _Client = client
25
39
 
26
- def __iter__(self):
27
- self.index = -1
28
- return self
40
+ # shallow copy partly guards against mutating the original input
41
+ self.variables: dict[str, Any] = dict(variables)
29
42
 
30
- def __len__(self):
31
- if self.length is None:
32
- self._load_page()
33
- if self.length is None:
34
- raise ValueError("Object doesn't provide length")
35
- return self.length
43
+ self.per_page: int = per_page
44
+ self.objects: list[T] = []
45
+ self.index: int = -1
46
+ self.last_response: object | None = None
36
47
 
37
- @property
38
- def length(self):
39
- raise NotImplementedError
48
+ def __iter__(self) -> Iterator[T]:
49
+ self.index = -1
50
+ return self
40
51
 
41
52
  @property
42
- def more(self):
53
+ @abstractmethod
54
+ def more(self) -> bool:
55
+ """Whether there are more pages to be fetched."""
43
56
  raise NotImplementedError
44
57
 
45
58
  @property
46
- def cursor(self):
59
+ @abstractmethod
60
+ def cursor(self) -> str | None:
61
+ """The start cursor to use for the next fetched page."""
47
62
  raise NotImplementedError
48
63
 
49
- def convert_objects(self):
64
+ @abstractmethod
65
+ def convert_objects(self) -> list[T]:
66
+ """Convert the last fetched response data into the iterated objects."""
50
67
  raise NotImplementedError
51
68
 
52
- def update_variables(self):
69
+ def update_variables(self) -> None:
70
+ """Update the query variables for the next page fetch."""
53
71
  self.variables.update({"perPage": self.per_page, "cursor": self.cursor})
54
72
 
55
- def _load_page(self):
56
- if not self.more:
57
- return False
58
- self.update_variables()
73
+ def _update_response(self) -> None:
74
+ """Fetch and store the response data for the next page."""
59
75
  self.last_response = self.client.execute(
60
76
  self.QUERY, variable_values=self.variables
61
77
  )
78
+
79
+ def _load_page(self) -> bool:
80
+ """Fetch the next page, if any, returning True and storing the response if there was one."""
81
+ if not self.more:
82
+ return False
83
+ self.update_variables()
84
+ self._update_response()
62
85
  self.objects.extend(self.convert_objects())
63
86
  return True
64
87
 
65
- def __getitem__(self, index):
88
+ @overload
89
+ def __getitem__(self, index: int) -> T: ...
90
+ @overload
91
+ def __getitem__(self, index: slice) -> list[T]: ...
92
+
93
+ def __getitem__(self, index: int | slice) -> T | list[T]:
66
94
  loaded = True
67
95
  stop = index.stop if isinstance(index, slice) else index
68
96
  while loaded and stop > len(self.objects) - 1:
69
97
  loaded = self._load_page()
70
98
  return self.objects[index]
71
99
 
72
- def __next__(self):
100
+ def __next__(self) -> T:
73
101
  self.index += 1
74
102
  if len(self.objects) <= self.index:
75
103
  if not self._load_page():
@@ -79,3 +107,19 @@ class Paginator:
79
107
  return self.objects[self.index]
80
108
 
81
109
  next = __next__
110
+
111
+
112
+ class SizedPaginator(Paginator[T], Sized):
113
+ """A Paginator for objects with a known total count."""
114
+
115
+ def __len__(self) -> int:
116
+ if self.length is None:
117
+ self._load_page()
118
+ if self.length is None:
119
+ raise ValueError("Object doesn't provide length")
120
+ return self.length
121
+
122
+ @property
123
+ @abstractmethod
124
+ def length(self) -> int | None:
125
+ raise NotImplementedError