yera 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.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.
- infra_mvp/base_client.py +29 -0
- infra_mvp/base_server.py +68 -0
- infra_mvp/monitoring/__init__.py +15 -0
- infra_mvp/monitoring/metrics.py +185 -0
- infra_mvp/stream/README.md +56 -0
- infra_mvp/stream/__init__.py +14 -0
- infra_mvp/stream/__main__.py +101 -0
- infra_mvp/stream/agents/demos/financial/chart_additions_plan.md +170 -0
- infra_mvp/stream/agents/demos/financial/portfolio_assistant_stream.json +1571 -0
- infra_mvp/stream/agents/reference/blocks/action.json +170 -0
- infra_mvp/stream/agents/reference/blocks/button.json +66 -0
- infra_mvp/stream/agents/reference/blocks/date.json +65 -0
- infra_mvp/stream/agents/reference/blocks/input_prompt.json +94 -0
- infra_mvp/stream/agents/reference/blocks/layout.json +288 -0
- infra_mvp/stream/agents/reference/blocks/markdown.json +344 -0
- infra_mvp/stream/agents/reference/blocks/slider.json +67 -0
- infra_mvp/stream/agents/reference/blocks/spinner.json +110 -0
- infra_mvp/stream/agents/reference/blocks/table.json +56 -0
- infra_mvp/stream/agents/reference/chat_dynamics/branching_test_stream.json +145 -0
- infra_mvp/stream/app.py +49 -0
- infra_mvp/stream/container.py +112 -0
- infra_mvp/stream/schemas/__init__.py +16 -0
- infra_mvp/stream/schemas/agent.py +24 -0
- infra_mvp/stream/schemas/interaction.py +28 -0
- infra_mvp/stream/schemas/session.py +30 -0
- infra_mvp/stream/server.py +321 -0
- infra_mvp/stream/services/__init__.py +12 -0
- infra_mvp/stream/services/agent_service.py +40 -0
- infra_mvp/stream/services/event_converter.py +83 -0
- infra_mvp/stream/services/session_service.py +247 -0
- yera/__init__.py +50 -1
- yera/agents/__init__.py +2 -0
- yera/agents/context.py +41 -0
- yera/agents/dataclasses.py +69 -0
- yera/agents/decorator.py +207 -0
- yera/agents/discovery.py +124 -0
- yera/agents/typing/__init__.py +0 -0
- yera/agents/typing/coerce.py +408 -0
- yera/agents/typing/utils.py +19 -0
- yera/agents/typing/validate.py +206 -0
- yera/cli.py +377 -0
- yera/config/__init__.py +1 -0
- yera/config/config_utils.py +164 -0
- yera/config/function_config.py +55 -0
- yera/config/logging.py +18 -0
- yera/config/tool_config.py +8 -0
- yera/config2/__init__.py +8 -0
- yera/config2/dataclasses.py +534 -0
- yera/config2/keyring.py +270 -0
- yera/config2/paths.py +28 -0
- yera/config2/read.py +113 -0
- yera/config2/setup.py +109 -0
- yera/config2/setup_handlers/__init__.py +1 -0
- yera/config2/setup_handlers/anthropic.py +126 -0
- yera/config2/setup_handlers/azure.py +236 -0
- yera/config2/setup_handlers/base.py +125 -0
- yera/config2/setup_handlers/llama_cpp.py +205 -0
- yera/config2/setup_handlers/ollama.py +157 -0
- yera/config2/setup_handlers/openai.py +137 -0
- yera/config2/write.py +87 -0
- yera/dsl/__init__.py +0 -0
- yera/dsl/functions.py +94 -0
- yera/dsl/struct.py +20 -0
- yera/dsl/workspace.py +79 -0
- yera/events/__init__.py +57 -0
- yera/events/blocks/__init__.py +68 -0
- yera/events/blocks/action.py +57 -0
- yera/events/blocks/bar_chart.py +92 -0
- yera/events/blocks/base/__init__.py +20 -0
- yera/events/blocks/base/base.py +166 -0
- yera/events/blocks/base/chart.py +288 -0
- yera/events/blocks/base/layout.py +111 -0
- yera/events/blocks/buttons.py +37 -0
- yera/events/blocks/columns.py +26 -0
- yera/events/blocks/container.py +24 -0
- yera/events/blocks/date_picker.py +50 -0
- yera/events/blocks/exit.py +39 -0
- yera/events/blocks/form.py +24 -0
- yera/events/blocks/input_echo.py +22 -0
- yera/events/blocks/input_request.py +31 -0
- yera/events/blocks/line_chart.py +97 -0
- yera/events/blocks/markdown.py +67 -0
- yera/events/blocks/slider.py +54 -0
- yera/events/blocks/spinner.py +55 -0
- yera/events/blocks/system_prompt.py +22 -0
- yera/events/blocks/table.py +291 -0
- yera/events/models/__init__.py +39 -0
- yera/events/models/block_data.py +112 -0
- yera/events/models/in_event.py +7 -0
- yera/events/models/out_event.py +75 -0
- yera/events/runtime.py +187 -0
- yera/events/stream.py +91 -0
- yera/models/__init__.py +0 -0
- yera/models/data_classes.py +20 -0
- yera/models/llm_atlas_proxy.py +44 -0
- yera/models/llm_context.py +99 -0
- yera/models/llm_interfaces/__init__.py +0 -0
- yera/models/llm_interfaces/anthropic.py +153 -0
- yera/models/llm_interfaces/aws_bedrock.py +14 -0
- yera/models/llm_interfaces/azure_openai.py +143 -0
- yera/models/llm_interfaces/base.py +26 -0
- yera/models/llm_interfaces/interface_registry.py +74 -0
- yera/models/llm_interfaces/llama_cpp.py +136 -0
- yera/models/llm_interfaces/mock.py +29 -0
- yera/models/llm_interfaces/ollama_interface.py +118 -0
- yera/models/llm_interfaces/open_ai.py +150 -0
- yera/models/llm_workspace.py +19 -0
- yera/models/model_atlas.py +139 -0
- yera/models/model_definition.py +38 -0
- yera/models/model_factory.py +33 -0
- yera/opaque/__init__.py +9 -0
- yera/opaque/base.py +20 -0
- yera/opaque/decorator.py +8 -0
- yera/opaque/markdown.py +57 -0
- yera/opaque/opaque_function.py +25 -0
- yera/tools/__init__.py +29 -0
- yera/tools/atlas_tool.py +20 -0
- yera/tools/base.py +24 -0
- yera/tools/decorated_tool.py +18 -0
- yera/tools/decorator.py +35 -0
- yera/tools/tool_atlas.py +51 -0
- yera/tools/tool_utils.py +361 -0
- yera/ui/dist/404.html +1 -0
- yera/ui/dist/__next.__PAGE__.txt +10 -0
- yera/ui/dist/__next._full.txt +23 -0
- yera/ui/dist/__next._head.txt +6 -0
- yera/ui/dist/__next._index.txt +5 -0
- yera/ui/dist/__next._tree.txt +7 -0
- yera/ui/dist/_next/static/chunks/4c4688e1ff21ad98.js +1 -0
- yera/ui/dist/_next/static/chunks/652cd53c27924d50.js +4 -0
- yera/ui/dist/_next/static/chunks/786d2107b51e8499.css +1 -0
- yera/ui/dist/_next/static/chunks/7de9141b1af425c3.js +1 -0
- yera/ui/dist/_next/static/chunks/87ef65064d3524c1.js +2 -0
- yera/ui/dist/_next/static/chunks/a6dad97d9634a72d.js +1 -0
- yera/ui/dist/_next/static/chunks/a6dad97d9634a72d.js.map +1 -0
- yera/ui/dist/_next/static/chunks/c4c79d5d0b280aeb.js +1 -0
- yera/ui/dist/_next/static/chunks/dc2d2a247505d66f.css +5 -0
- yera/ui/dist/_next/static/chunks/f773f714b55ec620.js +37 -0
- yera/ui/dist/_next/static/chunks/turbopack-98b3031e1b1dbc33.js +4 -0
- yera/ui/dist/_next/static/lnhYLzJ1-a5EfNbW1uFF6/_buildManifest.js +11 -0
- yera/ui/dist/_next/static/lnhYLzJ1-a5EfNbW1uFF6/_clientMiddlewareManifest.json +1 -0
- yera/ui/dist/_next/static/lnhYLzJ1-a5EfNbW1uFF6/_ssgManifest.js +1 -0
- yera/ui/dist/_next/static/media/14e23f9b59180572-s.9c448f3c.woff2 +0 -0
- yera/ui/dist/_next/static/media/2a65768255d6b625-s.p.d19752fb.woff2 +0 -0
- yera/ui/dist/_next/static/media/2b2eb4836d2dad95-s.f36de3af.woff2 +0 -0
- yera/ui/dist/_next/static/media/31183d9fd602dc89-s.c4ff9b73.woff2 +0 -0
- yera/ui/dist/_next/static/media/3fcb63a1ac6a562e-s.2f77a576.woff2 +0 -0
- yera/ui/dist/_next/static/media/45ec8de98929b0f6-s.81056204.woff2 +0 -0
- yera/ui/dist/_next/static/media/4fa387ec64143e14-s.c1fdd6c2.woff2 +0 -0
- yera/ui/dist/_next/static/media/65c558afe41e89d6-s.e2c8389a.woff2 +0 -0
- yera/ui/dist/_next/static/media/67add6cc0f54b8cf-s.8ce53448.woff2 +0 -0
- yera/ui/dist/_next/static/media/7178b3e590c64307-s.b97b3418.woff2 +0 -0
- yera/ui/dist/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2 +0 -0
- yera/ui/dist/_next/static/media/8a480f0b521d4e75-s.8e0177b5.woff2 +0 -0
- yera/ui/dist/_next/static/media/a8ff2d5d0ccb0d12-s.fc5b72a7.woff2 +0 -0
- yera/ui/dist/_next/static/media/aae5f0be330e13db-s.p.853e26d6.woff2 +0 -0
- yera/ui/dist/_next/static/media/b11a6ccf4a3edec7-s.2113d282.woff2 +0 -0
- yera/ui/dist/_next/static/media/b49b0d9b851e4899-s.4f3fa681.woff2 +0 -0
- yera/ui/dist/_next/static/media/bbc41e54d2fcbd21-s.799d8ef8.woff2 +0 -0
- yera/ui/dist/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2 +0 -0
- yera/ui/dist/_next/static/media/favicon.0b3bf435.ico +0 -0
- yera/ui/dist/_not-found/__next._full.txt +14 -0
- yera/ui/dist/_not-found/__next._head.txt +6 -0
- yera/ui/dist/_not-found/__next._index.txt +5 -0
- yera/ui/dist/_not-found/__next._not-found.__PAGE__.txt +5 -0
- yera/ui/dist/_not-found/__next._not-found.txt +4 -0
- yera/ui/dist/_not-found/__next._tree.txt +2 -0
- yera/ui/dist/_not-found.html +1 -0
- yera/ui/dist/_not-found.txt +14 -0
- yera/ui/dist/agent-icon.svg +3 -0
- yera/ui/dist/favicon.ico +0 -0
- yera/ui/dist/file.svg +1 -0
- yera/ui/dist/globe.svg +1 -0
- yera/ui/dist/index.html +1 -0
- yera/ui/dist/index.txt +23 -0
- yera/ui/dist/logo/full_logo.png +0 -0
- yera/ui/dist/logo/rune_logo.png +0 -0
- yera/ui/dist/logo/rune_logo_borderless.png +0 -0
- yera/ui/dist/logo/text_logo.png +0 -0
- yera/ui/dist/next.svg +1 -0
- yera/ui/dist/send.png +0 -0
- yera/ui/dist/send_single.png +0 -0
- yera/ui/dist/vercel.svg +1 -0
- yera/ui/dist/window.svg +1 -0
- yera/utils/__init__.py +1 -0
- yera/utils/path_utils.py +38 -0
- yera-0.2.0.dist-info/METADATA +65 -0
- yera-0.2.0.dist-info/RECORD +190 -0
- {yera-0.1.1.dist-info → yera-0.2.0.dist-info}/WHEEL +1 -1
- yera-0.2.0.dist-info/entry_points.txt +2 -0
- yera-0.1.1.dist-info/METADATA +0 -11
- yera-0.1.1.dist-info/RECORD +0 -4
yera/agents/discovery.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Utilities for loading agents from files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from yera.agents.decorator import AgentFunctionWrapper
|
|
10
|
+
from yera.utils.path_utils import path_to_module_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_agent(
|
|
14
|
+
file_path: Path | str, agent_identifier: str | None = None
|
|
15
|
+
) -> AgentFunctionWrapper:
|
|
16
|
+
"""Load a Python file and extract the AgentFunctionWrapper instance with the given identifier.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
file_path: Path to the Python file containing one or more agents.
|
|
20
|
+
Can be a Path object or string.
|
|
21
|
+
agent_identifier: Identifier of the agent to load. If None, the first agent is loaded.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
AgentFunctionWrapper instance.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If no agents are found in the file.
|
|
28
|
+
ValueError: If multiple agents are found in the file and no identifier is provided.
|
|
29
|
+
ValueError: If the specified agent identifier is not found in the file.
|
|
30
|
+
ValueError: If the specified agent identifier does not match the identifier of the loaded agent.
|
|
31
|
+
"""
|
|
32
|
+
agents = load_agents(file_path)
|
|
33
|
+
|
|
34
|
+
if not agents:
|
|
35
|
+
raise ValueError(f"No agents found in {file_path}")
|
|
36
|
+
|
|
37
|
+
if agent_identifier is None:
|
|
38
|
+
if len(agents) > 1:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Multiple agents found in {file_path}, please specify an agent identifier"
|
|
41
|
+
)
|
|
42
|
+
return agents[0]
|
|
43
|
+
|
|
44
|
+
matching_agents = [
|
|
45
|
+
agent for agent in agents if agent.metadata.identifier == agent_identifier
|
|
46
|
+
]
|
|
47
|
+
if not matching_agents:
|
|
48
|
+
raise ValueError(f"Agent with ID {agent_identifier} not found in {file_path}")
|
|
49
|
+
if len(matching_agents) > 1:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Multiple agents with ID {agent_identifier} found in {file_path}"
|
|
52
|
+
)
|
|
53
|
+
return matching_agents[0]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_agents(file_path: Path | str) -> list[AgentFunctionWrapper]:
|
|
57
|
+
"""Load a Python file and extract all AgentFunctionWrapper instances.
|
|
58
|
+
|
|
59
|
+
This helper looks for objects in the module that are instances of
|
|
60
|
+
AgentFunctionWrapper (created by the @yr.agent decorator).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
file_path: Path to the Python file containing one or more agents.
|
|
64
|
+
Can be a Path object or string.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dictionary mapping agent identifiers to AgentFunctionWrapper instances.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
FileNotFoundError: If the file does not exist.
|
|
71
|
+
"""
|
|
72
|
+
path = Path(file_path) if isinstance(file_path, str) else file_path
|
|
73
|
+
if not path.exists():
|
|
74
|
+
raise FileNotFoundError(f"Python file not found: {path}")
|
|
75
|
+
|
|
76
|
+
# Load the module from the given file path
|
|
77
|
+
module_name = path_to_module_name(path)
|
|
78
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
79
|
+
if spec is None or spec.loader is None:
|
|
80
|
+
raise FileNotFoundError(f"Could not load Python module from: {path}")
|
|
81
|
+
|
|
82
|
+
module = importlib.util.module_from_spec(spec)
|
|
83
|
+
# Exec here is fine because we are only allowing users to run via the CLI locally,
|
|
84
|
+
# so there is no risk of arbitrary remote code execution.
|
|
85
|
+
# Deployment will require tracing, which will NOT exec the python directly.
|
|
86
|
+
spec.loader.exec_module(module)
|
|
87
|
+
|
|
88
|
+
# Collect all AgentFunctionWrapper instances defined in the module
|
|
89
|
+
return [
|
|
90
|
+
value
|
|
91
|
+
for value in vars(module).values()
|
|
92
|
+
if isinstance(value, AgentFunctionWrapper)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover_agents(directory: Path | str) -> list[AgentFunctionWrapper]:
|
|
97
|
+
"""Discover all agents in a directory.
|
|
98
|
+
|
|
99
|
+
Scans the directory for .py files and loads all agents from each file.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
directory: Directory to scan for agent files.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of AgentFunctionWrapper instances.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
FileNotFoundError: If the directory does not exist.
|
|
109
|
+
"""
|
|
110
|
+
dir_path = Path(directory) if isinstance(directory, str) else directory
|
|
111
|
+
if not dir_path.exists():
|
|
112
|
+
raise FileNotFoundError(f"Directory not found: {dir_path}")
|
|
113
|
+
if not dir_path.is_dir():
|
|
114
|
+
raise ValueError(f"Path is not a directory: {dir_path}")
|
|
115
|
+
|
|
116
|
+
all_agents = []
|
|
117
|
+
for py_file in dir_path.rglob("*.py"):
|
|
118
|
+
try:
|
|
119
|
+
all_agents.extend(load_agents(py_file))
|
|
120
|
+
# Ignore PERF203 because I/O file loading is the real bottleneck.
|
|
121
|
+
except FileNotFoundError: # noqa: PERF203
|
|
122
|
+
logging.warning(f"Failed to load agents from {py_file}")
|
|
123
|
+
continue
|
|
124
|
+
return all_agents
|
|
File without changes
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from datetime import date, datetime, time, timedelta
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from types import UnionType
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, Union, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from yera.agents.typing.utils import get_type_name
|
|
13
|
+
from yera.agents.typing.validate import is_enum_type, is_struct_type
|
|
14
|
+
from yera.dsl.struct import Struct
|
|
15
|
+
|
|
16
|
+
from .validate import is_dataframe_type
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def coerce_set(value: Any, expected_type: Any) -> set:
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
try:
|
|
22
|
+
value = json.loads(value)
|
|
23
|
+
except json.JSONDecodeError as e:
|
|
24
|
+
raise TypeError(f"Cannot parse set from JSON string: {e}") from None
|
|
25
|
+
|
|
26
|
+
if isinstance(value, set | list):
|
|
27
|
+
items = value
|
|
28
|
+
else:
|
|
29
|
+
raise TypeError(f"Expected set or list, got {type(value).__name__}")
|
|
30
|
+
|
|
31
|
+
type_args = get_args(expected_type)
|
|
32
|
+
if not type_args:
|
|
33
|
+
raise TypeError(
|
|
34
|
+
"Set type must specify element type (e.g., set[int], not plain set)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
element_type = type_args[0]
|
|
38
|
+
return {coerce_value(item, element_type) for i, item in enumerate(items)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def coerce_datetime(value: Any) -> datetime:
|
|
42
|
+
"""Coerce to datetime."""
|
|
43
|
+
if isinstance(value, datetime):
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
# Try ISO format first
|
|
47
|
+
try:
|
|
48
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
49
|
+
except ValueError:
|
|
50
|
+
# Fallback to common formats
|
|
51
|
+
for fmt in [
|
|
52
|
+
"%Y-%m-%d %H:%M:%S",
|
|
53
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
54
|
+
"%Y-%m-%d %H:%M:%S.%f",
|
|
55
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
56
|
+
]:
|
|
57
|
+
try:
|
|
58
|
+
return datetime.strptime(value, fmt)
|
|
59
|
+
except ValueError: # noqa: PERF203
|
|
60
|
+
continue
|
|
61
|
+
raise TypeError(f"Cannot parse datetime from string: {value!r}") from None
|
|
62
|
+
elif isinstance(value, int | float):
|
|
63
|
+
# Unix timestamp
|
|
64
|
+
try:
|
|
65
|
+
return datetime.fromtimestamp(value)
|
|
66
|
+
except (ValueError, OSError) as e:
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"Cannot convert timestamp {value} to datetime: {e}"
|
|
69
|
+
) from None
|
|
70
|
+
else:
|
|
71
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to datetime")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def coerce_date(value: Any) -> date:
|
|
75
|
+
"""Coerce to date."""
|
|
76
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
77
|
+
return value
|
|
78
|
+
if isinstance(value, datetime):
|
|
79
|
+
return value.date()
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
try:
|
|
82
|
+
return date.fromisoformat(value)
|
|
83
|
+
except ValueError:
|
|
84
|
+
# Try parsing as datetime and extract date
|
|
85
|
+
try:
|
|
86
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
87
|
+
return dt.date()
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise TypeError(f"Cannot parse date from string: {value!r}") from None
|
|
90
|
+
else:
|
|
91
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to date")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def coerce_time(value: Any) -> time:
|
|
95
|
+
"""Coerce to time."""
|
|
96
|
+
if isinstance(value, time):
|
|
97
|
+
return value
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
try:
|
|
100
|
+
return time.fromisoformat(value)
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"Cannot convert {type(value).__name__} to time. "
|
|
104
|
+
f"Expected ISO format time."
|
|
105
|
+
) from None
|
|
106
|
+
else:
|
|
107
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to time")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def coerce_timedelta(value: Any) -> timedelta:
|
|
111
|
+
"""Coerce to timedelta."""
|
|
112
|
+
if isinstance(value, timedelta):
|
|
113
|
+
return value
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
try:
|
|
116
|
+
return timedelta(seconds=float(value))
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise TypeError(
|
|
119
|
+
f"Cannot convert timedelta from invalid string: {value!r}. "
|
|
120
|
+
f"Expected a float for number of seconds."
|
|
121
|
+
) from None
|
|
122
|
+
else:
|
|
123
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to timedelta")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def coerce_decimal(value: Any) -> Decimal:
|
|
127
|
+
"""Coerce to Decimal."""
|
|
128
|
+
if isinstance(value, Decimal):
|
|
129
|
+
return value
|
|
130
|
+
if isinstance(value, int | float | str):
|
|
131
|
+
try:
|
|
132
|
+
return Decimal(str(value))
|
|
133
|
+
except (ValueError, InvalidOperation) as e:
|
|
134
|
+
raise TypeError(f"Cannot convert {value!r} to Decimal: {e}") from None
|
|
135
|
+
else:
|
|
136
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to Decimal")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def coerce_enum(value: Any, expected_type: type[Enum]) -> Enum:
|
|
140
|
+
"""Coerce to Enum type."""
|
|
141
|
+
if isinstance(value, expected_type):
|
|
142
|
+
return value
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
# Try by name first
|
|
145
|
+
try:
|
|
146
|
+
return expected_type[value]
|
|
147
|
+
except KeyError:
|
|
148
|
+
valid_names = [e.name for e in expected_type]
|
|
149
|
+
raise TypeError(
|
|
150
|
+
f"'{value}' string value is not a valid {expected_type.__name__}. "
|
|
151
|
+
f"Valid names: {valid_names}."
|
|
152
|
+
) from None
|
|
153
|
+
elif isinstance(value, int):
|
|
154
|
+
try:
|
|
155
|
+
return expected_type(value)
|
|
156
|
+
except ValueError:
|
|
157
|
+
valid_values = [e.value for e in expected_type]
|
|
158
|
+
raise TypeError(
|
|
159
|
+
f"{value} is not a valid {expected_type.__name__} value. "
|
|
160
|
+
f"Valid values: {valid_values}"
|
|
161
|
+
) from None
|
|
162
|
+
else:
|
|
163
|
+
raise TypeError(
|
|
164
|
+
f"Cannot convert {type(value).__name__} to {expected_type.__name__}. "
|
|
165
|
+
f"Expected str or int."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def serialise_dataframe(df: "pd.DataFrame") -> bytes:
|
|
170
|
+
"""Serialize DataFrame to Parquet bytes."""
|
|
171
|
+
buffer = io.BytesIO()
|
|
172
|
+
df.to_parquet(buffer, engine="pyarrow")
|
|
173
|
+
return buffer.getvalue()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def coerce_dataframe(value: Any) -> "pd.DataFrame":
|
|
177
|
+
"""Coerce to pandas DataFrame."""
|
|
178
|
+
import pandas as pd
|
|
179
|
+
|
|
180
|
+
if isinstance(value, pd.DataFrame):
|
|
181
|
+
return value
|
|
182
|
+
if isinstance(value, bytes):
|
|
183
|
+
return pd.read_parquet(io.BytesIO(value), engine="pyarrow")
|
|
184
|
+
raise TypeError(
|
|
185
|
+
f"Cannot convert {type(value).__name__} to DataFrame. "
|
|
186
|
+
f"Must be a pandas DataFrame or parquet bytes."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def coerce_none(value: Any) -> None:
|
|
191
|
+
"""Coerce to None type."""
|
|
192
|
+
if value is None or value == "None":
|
|
193
|
+
return
|
|
194
|
+
raise TypeError(f"Expected None, got {type(value).__name__}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def coerce_bool(value: Any) -> bool:
|
|
198
|
+
"""Coerce to bool with proper string handling."""
|
|
199
|
+
if isinstance(value, bool):
|
|
200
|
+
return value
|
|
201
|
+
if isinstance(value, str):
|
|
202
|
+
if value.lower() == "true":
|
|
203
|
+
return True
|
|
204
|
+
if value.lower() == "false":
|
|
205
|
+
return False
|
|
206
|
+
raise TypeError(
|
|
207
|
+
f"Cannot convert {value!r} to bool. "
|
|
208
|
+
f"Expected one of: true, false, 1, 0, yes, no, y, n, on, off (case-insensitive)"
|
|
209
|
+
)
|
|
210
|
+
if isinstance(value, int):
|
|
211
|
+
return bool(value)
|
|
212
|
+
raise TypeError(f"Cannot convert {type(value).__name__} to bool")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def coerce_str(value: Any) -> str:
|
|
216
|
+
"""Coerce to str with proper string handling."""
|
|
217
|
+
try:
|
|
218
|
+
return str(value)
|
|
219
|
+
except (ValueError, TypeError) as e:
|
|
220
|
+
raise TypeError(f"Cannot convert {value!r} to str. ") from None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def coerce_int(value: Any) -> int:
|
|
224
|
+
"""Coerce to int with proper string handling."""
|
|
225
|
+
try:
|
|
226
|
+
return int(value)
|
|
227
|
+
except (ValueError, TypeError) as e:
|
|
228
|
+
raise TypeError(f"Cannot convert {value!r} to int") from None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def coerce_float(value: Any) -> float:
|
|
232
|
+
"""Coerce to float with proper string handling."""
|
|
233
|
+
try:
|
|
234
|
+
return float(value)
|
|
235
|
+
except (ValueError, TypeError) as e:
|
|
236
|
+
raise TypeError(f"Cannot convert {value!r} to float") from None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def coerce_literal(value: Any, expected_type: Any) -> Any:
|
|
240
|
+
"""Coerce to Literal type."""
|
|
241
|
+
allowed_values = get_args(expected_type)
|
|
242
|
+
|
|
243
|
+
# First try exact match
|
|
244
|
+
if value in allowed_values:
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
# Try coercing to match one of the literal values
|
|
248
|
+
for literal_value in allowed_values:
|
|
249
|
+
literal_type = type(literal_value)
|
|
250
|
+
|
|
251
|
+
# Skip None in literals for coercion attempts
|
|
252
|
+
if literal_value is None:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# Try to coerce the input value to the type of this literal
|
|
257
|
+
coerced = coerce_value(value, literal_type)
|
|
258
|
+
if coerced == literal_value:
|
|
259
|
+
return literal_value
|
|
260
|
+
except (TypeError, ValueError):
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
# If no match found, raise error with helpful message
|
|
264
|
+
raise TypeError(
|
|
265
|
+
f"Value {value!r} is not one of the allowed literal values: {allowed_values}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def coerce_struct(value: Any, expected_type: type[Struct]) -> Any:
|
|
270
|
+
"""Coerce to Struct (Pydantic model) type."""
|
|
271
|
+
if isinstance(value, expected_type):
|
|
272
|
+
return value
|
|
273
|
+
if isinstance(value, dict):
|
|
274
|
+
return expected_type.model_validate(value)
|
|
275
|
+
if isinstance(value, str):
|
|
276
|
+
return expected_type.model_validate_json(value)
|
|
277
|
+
raise TypeError(
|
|
278
|
+
f"Cannot convert {type(value).__name__} to {expected_type.__name__}. "
|
|
279
|
+
f"Expected dict or JSON string."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def coerce_union(value: Any, expected_type: Any) -> Any:
|
|
284
|
+
"""Coerce to Union type by trying each type in order."""
|
|
285
|
+
type_args = get_args(expected_type)
|
|
286
|
+
last_error = None
|
|
287
|
+
|
|
288
|
+
for arg_type in type_args:
|
|
289
|
+
try:
|
|
290
|
+
return coerce_value(value, arg_type)
|
|
291
|
+
except (TypeError, ValueError) as e: # noqa: PERF203
|
|
292
|
+
last_error = e
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
raise TypeError(
|
|
296
|
+
f"Cannot coerce value to any of {type_args}. Last error: {last_error}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def coerce_list(value: Any, expected_type: Any) -> list:
|
|
301
|
+
"""Coerce to list[T] type."""
|
|
302
|
+
if isinstance(value, str):
|
|
303
|
+
try:
|
|
304
|
+
value = json.loads(value)
|
|
305
|
+
except json.JSONDecodeError as e:
|
|
306
|
+
raise TypeError(f"Cannot parse list from JSON string: {e}") from None
|
|
307
|
+
|
|
308
|
+
if not isinstance(value, list):
|
|
309
|
+
raise TypeError(f"Expected list, got {type(value).__name__}")
|
|
310
|
+
|
|
311
|
+
type_args = get_args(expected_type)
|
|
312
|
+
if not type_args:
|
|
313
|
+
raise TypeError(
|
|
314
|
+
"List type must specify element type (e.g., list[int], not plain list)"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
element_type = type_args[0]
|
|
318
|
+
return [coerce_value(item, element_type) for i, item in enumerate(value)]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def coerce_dict(value: Any, expected_type: Any) -> dict:
|
|
322
|
+
"""Coerce to dict[K, V] type."""
|
|
323
|
+
# Handle string input (JSON)
|
|
324
|
+
if isinstance(value, str):
|
|
325
|
+
try:
|
|
326
|
+
value = json.loads(value)
|
|
327
|
+
except json.JSONDecodeError as e:
|
|
328
|
+
raise TypeError(f"Cannot parse dict from JSON string: {e}") from None
|
|
329
|
+
|
|
330
|
+
if not isinstance(value, dict):
|
|
331
|
+
raise TypeError(f"Expected dict, got {type(value).__name__}")
|
|
332
|
+
|
|
333
|
+
type_args = get_args(expected_type)
|
|
334
|
+
if not type_args:
|
|
335
|
+
raise TypeError(
|
|
336
|
+
"Dict type must specify key and value types (e.g., dict[str, int], "
|
|
337
|
+
"not plain dict)"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
key_type, value_type = type_args[0], type_args[1]
|
|
341
|
+
return {
|
|
342
|
+
coerce_value(k, key_type): coerce_value(v, value_type) for k, v in value.items()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def coerce_fallback(value: Any, expected_type: Any) -> Any:
|
|
347
|
+
"""Fallback coercion - just check isinstance."""
|
|
348
|
+
raise TypeError(
|
|
349
|
+
f"Unsupported type {get_type_name(expected_type)} for coercion of value "
|
|
350
|
+
f"{value}. Type must be validated as allowed before coercion."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
PARAMETERISED_TYPES = (list, set, dict, Literal, Union, UnionType)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def is_generic(t: type) -> bool:
|
|
358
|
+
return t in PARAMETERISED_TYPES
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
TYPE_COERCERS = {
|
|
362
|
+
bool: coerce_bool,
|
|
363
|
+
int: coerce_int,
|
|
364
|
+
float: coerce_float,
|
|
365
|
+
str: coerce_str,
|
|
366
|
+
type(None): coerce_none,
|
|
367
|
+
datetime: coerce_datetime,
|
|
368
|
+
date: coerce_date,
|
|
369
|
+
time: coerce_time,
|
|
370
|
+
timedelta: coerce_timedelta,
|
|
371
|
+
Decimal: coerce_decimal,
|
|
372
|
+
list: coerce_list,
|
|
373
|
+
dict: coerce_dict,
|
|
374
|
+
set: coerce_set,
|
|
375
|
+
Literal: coerce_literal,
|
|
376
|
+
Union: coerce_union,
|
|
377
|
+
UnionType: coerce_union,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def coerce_value(value: Any, expected_type: Any) -> Any:
|
|
382
|
+
"""Coerce a value to the expected type.
|
|
383
|
+
|
|
384
|
+
Delegates to specialized handlers based on type structure.
|
|
385
|
+
"""
|
|
386
|
+
if expected_type is None:
|
|
387
|
+
return coerce_none(value)
|
|
388
|
+
|
|
389
|
+
origin = get_origin(expected_type)
|
|
390
|
+
|
|
391
|
+
if is_struct_type(expected_type):
|
|
392
|
+
return coerce_struct(value, expected_type)
|
|
393
|
+
|
|
394
|
+
if is_enum_type(expected_type):
|
|
395
|
+
return coerce_enum(value, expected_type)
|
|
396
|
+
|
|
397
|
+
if is_dataframe_type(expected_type):
|
|
398
|
+
return coerce_dataframe(value)
|
|
399
|
+
|
|
400
|
+
handler = TYPE_COERCERS.get(origin or expected_type)
|
|
401
|
+
if handler is None:
|
|
402
|
+
return coerce_fallback(value, expected_type)
|
|
403
|
+
|
|
404
|
+
if is_generic(origin or expected_type):
|
|
405
|
+
# noinspection PyArgumentList
|
|
406
|
+
return handler(value, expected_type)
|
|
407
|
+
|
|
408
|
+
return handler(value)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import get_args, get_origin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_type_name(type_hint):
|
|
5
|
+
if type_hint is None or type_hint is type(None):
|
|
6
|
+
return "None"
|
|
7
|
+
|
|
8
|
+
origin = get_origin(type_hint)
|
|
9
|
+
if origin is not None:
|
|
10
|
+
args = get_args(type_hint)
|
|
11
|
+
if args:
|
|
12
|
+
arg_names = ", ".join(get_type_name(arg) for arg in args)
|
|
13
|
+
return f"{origin.__name__}[{arg_names}]"
|
|
14
|
+
return origin.__name__
|
|
15
|
+
|
|
16
|
+
if hasattr(type_hint, "__name__"):
|
|
17
|
+
return type_hint.__name__
|
|
18
|
+
|
|
19
|
+
return str(type_hint)
|