expr-tracker 0.1.7__tar.gz

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.
@@ -0,0 +1,53 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ release:
14
+ name: Build and publish to PyPI
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ # required for PyPI trusted publishing (OIDC)
18
+ id-token: write
19
+ # required to create the GitHub release
20
+ contents: write
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v5
24
+ with:
25
+ # uv-dynamic-versioning derives the version from git tags
26
+ fetch-depth: 0
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@v6
30
+ with:
31
+ enable-cache: true
32
+
33
+ - name: Build sdist and wheel
34
+ run: |
35
+ uv build
36
+ ls -l dist/
37
+
38
+ - name: Check distribution metadata
39
+ run: uvx twine check dist/*
40
+
41
+ - name: Publish to PyPI
42
+ if: startsWith(github.ref, 'refs/tags/v')
43
+ uses: pypa/gh-action-pypi-publish@release/v1
44
+
45
+ - name: Create GitHub release
46
+ if: startsWith(github.ref, 'refs/tags/v')
47
+ env:
48
+ GH_TOKEN: ${{ github.token }}
49
+ run: |
50
+ gh release create "${GITHUB_REF_NAME}" dist/* \
51
+ --repo "${GITHUB_REPOSITORY}" \
52
+ --title "${GITHUB_REF_NAME}" \
53
+ --generate-notes
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .python-version
9
+ wandb/
10
+ .env
11
+ # Virtual environments
12
+ .venv
13
+ test.ipynb
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: expr_tracker
3
+ Version: 0.1.7
4
+ Summary: Add your description here
5
+ Author-email: HSPK <whxway@whu.edu.cn>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: jsonlines>=4.0.0
8
+ Requires-Dist: loguru>=0.7.3
9
+ Requires-Dist: slark>=0.1.28
10
+ Requires-Dist: wandb>=0.21.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Experiment Tracker
14
+
15
+ A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
16
+
17
+ ## Features
18
+ 1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
19
+ 2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
20
+ 3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
21
+ 4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
22
+
23
+ ## Usage
24
+
25
+ Add dependency to your project:
26
+
27
+ ```bash
28
+ uv add expr_tracker
29
+ ```
30
+
31
+ Simple usage example:
32
+ ```python
33
+ import expr_tracker as et
34
+
35
+ et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
36
+ et.log({"accuracy": 0.95, "loss": 0.05})
37
+ et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
38
+ et.finish()
39
+ ```
40
+
41
+ ### JSONL buffering
42
+
43
+ The `jsonl` backend adapts its buffering to how often you call `log()`, so that
44
+ high-frequency logging doesn't hammer the disk (helpful on network mounts such as
45
+ BlobFuse) while low-frequency logging still lands on disk immediately:
46
+
47
+ - If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
48
+ the call is considered low-frequency and is written straight through.
49
+ - Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
50
+ records accumulate.
51
+ - A background timer flushes records that have been buffered for more than
52
+ `max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
53
+ in memory. `finish()` and the `atexit` hook flush any remainder.
54
+
55
+ Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
56
+ to disable the background timer. Tune them via `backend_kwargs`:
57
+
58
+ ```python
59
+ et.init(
60
+ project="my_project",
61
+ name="my_experiment",
62
+ backends=["jsonl"],
63
+ backend_kwargs={
64
+ "jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
65
+ },
66
+ )
67
+ ```
@@ -0,0 +1,55 @@
1
+ # Experiment Tracker
2
+
3
+ A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
4
+
5
+ ## Features
6
+ 1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
7
+ 2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
8
+ 3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
9
+ 4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
10
+
11
+ ## Usage
12
+
13
+ Add dependency to your project:
14
+
15
+ ```bash
16
+ uv add expr_tracker
17
+ ```
18
+
19
+ Simple usage example:
20
+ ```python
21
+ import expr_tracker as et
22
+
23
+ et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
24
+ et.log({"accuracy": 0.95, "loss": 0.05})
25
+ et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
26
+ et.finish()
27
+ ```
28
+
29
+ ### JSONL buffering
30
+
31
+ The `jsonl` backend adapts its buffering to how often you call `log()`, so that
32
+ high-frequency logging doesn't hammer the disk (helpful on network mounts such as
33
+ BlobFuse) while low-frequency logging still lands on disk immediately:
34
+
35
+ - If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
36
+ the call is considered low-frequency and is written straight through.
37
+ - Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
38
+ records accumulate.
39
+ - A background timer flushes records that have been buffered for more than
40
+ `max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
41
+ in memory. `finish()` and the `atexit` hook flush any remainder.
42
+
43
+ Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
44
+ to disable the background timer. Tune them via `backend_kwargs`:
45
+
46
+ ```python
47
+ et.init(
48
+ project="my_project",
49
+ name="my_experiment",
50
+ backends=["jsonl"],
51
+ backend_kwargs={
52
+ "jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
53
+ },
54
+ )
55
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "expr_tracker"
3
+ description = "Add your description here"
4
+ readme = "README.md"
5
+ authors = [{ name = "HSPK", email = "whxway@whu.edu.cn" }]
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "jsonlines>=4.0.0",
9
+ "loguru>=0.7.3",
10
+ "slark>=0.1.28",
11
+ "wandb>=0.21.0",
12
+ ]
13
+ dynamic = ["version"]
14
+
15
+ [project.scripts]
16
+ et = "expr_tracker.cli:main"
17
+
18
+ [dependency-groups]
19
+ dev = ["ipykernel>=6.30.1"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling", "uv-dynamic-versioning"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.version]
26
+ source = "uv-dynamic-versioning"
@@ -0,0 +1,9 @@
1
+ from . import tracker
2
+ from .alert import alert
3
+
4
+ init = tracker.init
5
+ finish = tracker.finish
6
+ log = tracker.log
7
+ info = tracker.info
8
+
9
+ __all__ = ["init", "finish", "log", "info", "alert"]
@@ -0,0 +1,31 @@
1
+ from typing import Any, Type
2
+
3
+ from pydantic import BaseModel
4
+ from pydantic.version import VERSION as PYDANTIC_VERSION
5
+ from typing_extensions import Literal
6
+
7
+ PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2])
8
+ PYDANTIC_V2 = PYDANTIC_VERSION_MINOR_TUPLE[0] == 2
9
+
10
+ Url: Type[Any]
11
+
12
+ if PYDANTIC_V2:
13
+ from pydantic_core import PydanticUndefinedType
14
+ from pydantic_core import Url as Url
15
+
16
+ UndefinedType = PydanticUndefinedType
17
+
18
+ def _model_dump(
19
+ model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
20
+ ) -> Any:
21
+ return model.model_dump(mode=mode, **kwargs)
22
+ else:
23
+ from pydantic import AnyUrl as Url # noqa: F401
24
+ from pydantic.fields import ( # type: ignore[no-redef, attr-defined]
25
+ UndefinedType as UndefinedType, # noqa: F401
26
+ )
27
+
28
+ def _model_dump(
29
+ model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
30
+ ) -> Any:
31
+ return model.dict(**kwargs)
@@ -0,0 +1,73 @@
1
+ from typing import Literal
2
+ from loguru import logger
3
+
4
+
5
+ def ignore_exception(func):
6
+ def wrapper(*args, **kwargs):
7
+ try:
8
+ return func(*args, **kwargs)
9
+ except Exception as e:
10
+ logger.warning(f"Error occurred in {func.__name__}: {e}")
11
+ return None
12
+
13
+ return wrapper
14
+
15
+
16
+ class LarkBackend:
17
+ def __init__(self, **kwargs):
18
+ from slark import Lark
19
+
20
+ self.lark = Lark(**kwargs)
21
+
22
+ def publish_alert(
23
+ self,
24
+ title: str,
25
+ text: str,
26
+ subtitle: str | None = None,
27
+ level: Literal["info", "warning", "error"] = "info",
28
+ traceback: str | None = None,
29
+ ):
30
+ if level == "info" or level == "warning":
31
+ self.lark.webhook.post_success_card(
32
+ msg=text, title=title, subtitle=subtitle
33
+ )
34
+ elif level == "error":
35
+ self.lark.webhook.post_error_card(
36
+ msg=text, traceback=traceback or "", title=title, subtitle=subtitle
37
+ )
38
+ else:
39
+ raise ValueError(
40
+ f"Unsupported level: {level}. Supported levels are 'info', 'warning', and 'error'."
41
+ )
42
+
43
+
44
+ @ignore_exception
45
+ def alert(
46
+ title: str,
47
+ text: str,
48
+ subtitle: str | None = None,
49
+ traceback: str | None = None,
50
+ level: Literal["info", "warning", "error"] = "info",
51
+ backends: list[Literal["lark", "slack", "email"]] = ["lark"],
52
+ ):
53
+ """
54
+ Send an alert message to specified backends.
55
+
56
+ Args:
57
+ title (str): The title of the alert.
58
+ text (str): The main content of the alert.
59
+ subtitle (str | None): Optional subtitle for the alert.
60
+ level (Literal["info", "warning", "error"]): The severity level of the alert.
61
+ backends (list[Literal["lark", "slack", "email"]]): List of backends to send the alert to.
62
+ """
63
+ for backend in backends:
64
+ if backend == "lark":
65
+ lark_backend = LarkBackend()
66
+ if lark_backend.lark._webhook_url is None:
67
+ logger.warning(
68
+ "Lark webhook URL is not set. Please set the WEBHOOK_URL environment variable."
69
+ )
70
+ return
71
+ lark_backend.publish_alert(title, text, subtitle, level, traceback)
72
+ else:
73
+ raise NotImplementedError(f"Backend '{backend}' is not implemented.")
@@ -0,0 +1,15 @@
1
+ import click
2
+ import expr_tracker as et
3
+
4
+
5
+ @click.group()
6
+ def main():
7
+ pass
8
+
9
+
10
+ @main.command()
11
+ @click.argument("msg")
12
+ @click.option("--title", default="Alert", help="Title of the alert")
13
+ @click.option("--level", default="info", help="Level of the alert")
14
+ def alert(msg: str, title: str = "Alert", level: str = "info"):
15
+ et.alert(title=title, text=msg, level=level)
@@ -0,0 +1,344 @@
1
+ import dataclasses
2
+ import datetime
3
+ from collections import defaultdict, deque
4
+ from decimal import Decimal
5
+ from enum import Enum
6
+ from ipaddress import (
7
+ IPv4Address,
8
+ IPv4Interface,
9
+ IPv4Network,
10
+ IPv6Address,
11
+ IPv6Interface,
12
+ IPv6Network,
13
+ )
14
+ from pathlib import Path, PurePath
15
+ from re import Pattern
16
+ from types import GeneratorType
17
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
18
+ from uuid import UUID
19
+
20
+ from expr_tracker.types import IncEx
21
+ from pydantic import BaseModel
22
+ from pydantic.color import Color
23
+ from pydantic.networks import AnyUrl, NameEmail
24
+ from pydantic.types import SecretBytes, SecretStr
25
+ from typing_extensions import Annotated, Doc
26
+
27
+ from ._compat import PYDANTIC_V2, UndefinedType, Url, _model_dump
28
+
29
+
30
+ # Taken from Pydantic v1 as is
31
+ def isoformat(o: Union[datetime.date, datetime.time]) -> str:
32
+ return o.isoformat()
33
+
34
+
35
+ # Taken from Pydantic v1 as is
36
+ # TODO: pv2 should this return strings instead?
37
+ def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
38
+ """
39
+ Encodes a Decimal as int of there's no exponent, otherwise float
40
+
41
+ This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
42
+ where a integer (but not int typed) is used. Encoding this as a float
43
+ results in failed round-tripping between encode and parse.
44
+ Our Id type is a prime example of this.
45
+
46
+ >>> decimal_encoder(Decimal("1.0"))
47
+ 1.0
48
+
49
+ >>> decimal_encoder(Decimal("1"))
50
+ 1
51
+ """
52
+ if dec_value.as_tuple().exponent >= 0: # type: ignore[operator]
53
+ return int(dec_value)
54
+ else:
55
+ return float(dec_value)
56
+
57
+
58
+ ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = {
59
+ bytes: lambda o: o.decode(),
60
+ Color: str,
61
+ datetime.date: isoformat,
62
+ datetime.datetime: isoformat,
63
+ datetime.time: isoformat,
64
+ datetime.timedelta: lambda td: td.total_seconds(),
65
+ Decimal: decimal_encoder,
66
+ Enum: lambda o: o.value,
67
+ frozenset: list,
68
+ deque: list,
69
+ GeneratorType: list,
70
+ IPv4Address: str,
71
+ IPv4Interface: str,
72
+ IPv4Network: str,
73
+ IPv6Address: str,
74
+ IPv6Interface: str,
75
+ IPv6Network: str,
76
+ NameEmail: str,
77
+ Path: str,
78
+ Pattern: lambda o: o.pattern,
79
+ SecretBytes: str,
80
+ SecretStr: str,
81
+ set: list,
82
+ UUID: str,
83
+ Url: str,
84
+ AnyUrl: str,
85
+ }
86
+
87
+
88
+ def generate_encoders_by_class_tuples(
89
+ type_encoder_map: Dict[Any, Callable[[Any], Any]],
90
+ ) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]:
91
+ encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(
92
+ tuple
93
+ )
94
+ for type_, encoder in type_encoder_map.items():
95
+ encoders_by_class_tuples[encoder] += (type_,)
96
+ return encoders_by_class_tuples
97
+
98
+
99
+ encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE)
100
+
101
+
102
+ def jsonable_encoder(
103
+ obj: Annotated[
104
+ Any,
105
+ Doc(
106
+ """
107
+ The input object to convert to JSON.
108
+ """
109
+ ),
110
+ ],
111
+ include: Annotated[
112
+ Optional[IncEx],
113
+ Doc(
114
+ """
115
+ Pydantic's `include` parameter, passed to Pydantic models to set the
116
+ fields to include.
117
+ """
118
+ ),
119
+ ] = None,
120
+ exclude: Annotated[
121
+ Optional[IncEx],
122
+ Doc(
123
+ """
124
+ Pydantic's `exclude` parameter, passed to Pydantic models to set the
125
+ fields to exclude.
126
+ """
127
+ ),
128
+ ] = None,
129
+ by_alias: Annotated[
130
+ bool,
131
+ Doc(
132
+ """
133
+ Pydantic's `by_alias` parameter, passed to Pydantic models to define if
134
+ the output should use the alias names (when provided) or the Python
135
+ attribute names. In an API, if you set an alias, it's probably because you
136
+ want to use it in the result, so you probably want to leave this set to
137
+ `True`.
138
+ """
139
+ ),
140
+ ] = True,
141
+ exclude_unset: Annotated[
142
+ bool,
143
+ Doc(
144
+ """
145
+ Pydantic's `exclude_unset` parameter, passed to Pydantic models to define
146
+ if it should exclude from the output the fields that were not explicitly
147
+ set (and that only had their default values).
148
+ """
149
+ ),
150
+ ] = False,
151
+ exclude_defaults: Annotated[
152
+ bool,
153
+ Doc(
154
+ """
155
+ Pydantic's `exclude_defaults` parameter, passed to Pydantic models to define
156
+ if it should exclude from the output the fields that had the same default
157
+ value, even when they were explicitly set.
158
+ """
159
+ ),
160
+ ] = False,
161
+ exclude_none: Annotated[
162
+ bool,
163
+ Doc(
164
+ """
165
+ Pydantic's `exclude_none` parameter, passed to Pydantic models to define
166
+ if it should exclude from the output any fields that have a `None` value.
167
+ """
168
+ ),
169
+ ] = False,
170
+ custom_encoder: Annotated[
171
+ Optional[Dict[Any, Callable[[Any], Any]]],
172
+ Doc(
173
+ """
174
+ Pydantic's `custom_encoder` parameter, passed to Pydantic models to define
175
+ a custom encoder.
176
+ """
177
+ ),
178
+ ] = None,
179
+ sqlalchemy_safe: Annotated[
180
+ bool,
181
+ Doc(
182
+ """
183
+ Exclude from the output any fields that start with the name `_sa`.
184
+
185
+ This is mainly a hack for compatibility with SQLAlchemy objects, they
186
+ store internal SQLAlchemy-specific state in attributes named with `_sa`,
187
+ and those objects can't (and shouldn't be) serialized to JSON.
188
+ """
189
+ ),
190
+ ] = True,
191
+ ) -> Any:
192
+ """
193
+ Convert any object to something that can be encoded in JSON.
194
+
195
+ This is used internally by FastAPI to make sure anything you return can be
196
+ encoded as JSON before it is sent to the client.
197
+
198
+ You can also use it yourself, for example to convert objects before saving them
199
+ in a database that supports only JSON.
200
+
201
+ Read more about it in the
202
+ [FastAPI docs for JSON Compatible Encoder](https://fastapi.tiangolo.com/tutorial/encoder/).
203
+ """
204
+ custom_encoder = custom_encoder or {}
205
+ if custom_encoder:
206
+ if type(obj) in custom_encoder:
207
+ return custom_encoder[type(obj)](obj)
208
+ else:
209
+ for encoder_type, encoder_instance in custom_encoder.items():
210
+ if isinstance(obj, encoder_type):
211
+ return encoder_instance(obj)
212
+ if include is not None and not isinstance(include, (set, dict)):
213
+ include = set(include)
214
+ if exclude is not None and not isinstance(exclude, (set, dict)):
215
+ exclude = set(exclude)
216
+ if isinstance(obj, BaseModel):
217
+ # TODO: remove when deprecating Pydantic v1
218
+ encoders: Dict[Any, Any] = {}
219
+ if not PYDANTIC_V2:
220
+ encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined]
221
+ if custom_encoder:
222
+ encoders = {**encoders, **custom_encoder}
223
+ obj_dict = _model_dump(
224
+ obj,
225
+ mode="json",
226
+ include=include,
227
+ exclude=exclude,
228
+ by_alias=by_alias,
229
+ exclude_unset=exclude_unset,
230
+ exclude_none=exclude_none,
231
+ exclude_defaults=exclude_defaults,
232
+ )
233
+ if "__root__" in obj_dict:
234
+ obj_dict = obj_dict["__root__"]
235
+ return jsonable_encoder(
236
+ obj_dict,
237
+ exclude_none=exclude_none,
238
+ exclude_defaults=exclude_defaults,
239
+ # TODO: remove when deprecating Pydantic v1
240
+ custom_encoder=encoders,
241
+ sqlalchemy_safe=sqlalchemy_safe,
242
+ )
243
+ if dataclasses.is_dataclass(obj):
244
+ assert not isinstance(obj, type)
245
+ obj_dict = dataclasses.asdict(obj)
246
+ return jsonable_encoder(
247
+ obj_dict,
248
+ include=include,
249
+ exclude=exclude,
250
+ by_alias=by_alias,
251
+ exclude_unset=exclude_unset,
252
+ exclude_defaults=exclude_defaults,
253
+ exclude_none=exclude_none,
254
+ custom_encoder=custom_encoder,
255
+ sqlalchemy_safe=sqlalchemy_safe,
256
+ )
257
+ if isinstance(obj, Enum):
258
+ return obj.value
259
+ if isinstance(obj, PurePath):
260
+ return str(obj)
261
+ if isinstance(obj, (str, int, float, type(None))):
262
+ return obj
263
+ if isinstance(obj, UndefinedType):
264
+ return None
265
+ if isinstance(obj, dict):
266
+ encoded_dict = {}
267
+ allowed_keys = set(obj.keys())
268
+ if include is not None:
269
+ allowed_keys &= set(include)
270
+ if exclude is not None:
271
+ allowed_keys -= set(exclude)
272
+ for key, value in obj.items():
273
+ if (
274
+ (
275
+ not sqlalchemy_safe
276
+ or (not isinstance(key, str))
277
+ or (not key.startswith("_sa"))
278
+ )
279
+ and (value is not None or not exclude_none)
280
+ and key in allowed_keys
281
+ ):
282
+ encoded_key = jsonable_encoder(
283
+ key,
284
+ by_alias=by_alias,
285
+ exclude_unset=exclude_unset,
286
+ exclude_none=exclude_none,
287
+ custom_encoder=custom_encoder,
288
+ sqlalchemy_safe=sqlalchemy_safe,
289
+ )
290
+ encoded_value = jsonable_encoder(
291
+ value,
292
+ by_alias=by_alias,
293
+ exclude_unset=exclude_unset,
294
+ exclude_none=exclude_none,
295
+ custom_encoder=custom_encoder,
296
+ sqlalchemy_safe=sqlalchemy_safe,
297
+ )
298
+ encoded_dict[encoded_key] = encoded_value
299
+ return encoded_dict
300
+ if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)):
301
+ encoded_list = []
302
+ for item in obj:
303
+ encoded_list.append(
304
+ jsonable_encoder(
305
+ item,
306
+ include=include,
307
+ exclude=exclude,
308
+ by_alias=by_alias,
309
+ exclude_unset=exclude_unset,
310
+ exclude_defaults=exclude_defaults,
311
+ exclude_none=exclude_none,
312
+ custom_encoder=custom_encoder,
313
+ sqlalchemy_safe=sqlalchemy_safe,
314
+ )
315
+ )
316
+ return encoded_list
317
+
318
+ if type(obj) in ENCODERS_BY_TYPE:
319
+ return ENCODERS_BY_TYPE[type(obj)](obj)
320
+ for encoder, classes_tuple in encoders_by_class_tuples.items():
321
+ if isinstance(obj, classes_tuple):
322
+ return encoder(obj)
323
+
324
+ try:
325
+ data = dict(obj)
326
+ except Exception as e:
327
+ errors: List[Exception] = []
328
+ errors.append(e)
329
+ try:
330
+ data = vars(obj)
331
+ except Exception as e:
332
+ errors.append(e)
333
+ raise ValueError(errors) from e
334
+ return jsonable_encoder(
335
+ data,
336
+ include=include,
337
+ exclude=exclude,
338
+ by_alias=by_alias,
339
+ exclude_unset=exclude_unset,
340
+ exclude_defaults=exclude_defaults,
341
+ exclude_none=exclude_none,
342
+ custom_encoder=custom_encoder,
343
+ sqlalchemy_safe=sqlalchemy_safe,
344
+ )