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.
- expr_tracker-0.1.7/.github/workflows/release.yaml +53 -0
- expr_tracker-0.1.7/.gitignore +13 -0
- expr_tracker-0.1.7/PKG-INFO +67 -0
- expr_tracker-0.1.7/README.md +55 -0
- expr_tracker-0.1.7/pyproject.toml +26 -0
- expr_tracker-0.1.7/src/expr_tracker/__init__.py +9 -0
- expr_tracker-0.1.7/src/expr_tracker/_compat.py +31 -0
- expr_tracker-0.1.7/src/expr_tracker/alert.py +73 -0
- expr_tracker-0.1.7/src/expr_tracker/cli.py +15 -0
- expr_tracker-0.1.7/src/expr_tracker/encoders.py +344 -0
- expr_tracker-0.1.7/src/expr_tracker/jsonl.py +209 -0
- expr_tracker-0.1.7/src/expr_tracker/tracker.py +167 -0
- expr_tracker-0.1.7/src/expr_tracker/types.py +10 -0
- expr_tracker-0.1.7/tests/test_jsonl_buffer.py +113 -0
- expr_tracker-0.1.7/tests/test_tracker.py +14 -0
- expr_tracker-0.1.7/uv.lock +1425 -0
|
@@ -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,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,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
|
+
)
|