planar 0.5.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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,385 @@
|
|
1
|
+
from datetime import datetime, timedelta
|
2
|
+
from unittest.mock import AsyncMock, patch
|
3
|
+
from uuid import UUID, uuid4
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
from pydantic import BaseModel, Field
|
7
|
+
from sqlmodel import col, select
|
8
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
9
|
+
|
10
|
+
from planar.human.human import (
|
11
|
+
Human,
|
12
|
+
HumanTask,
|
13
|
+
HumanTaskStatus,
|
14
|
+
Timeout,
|
15
|
+
complete_human_task,
|
16
|
+
)
|
17
|
+
from planar.workflows import suspend
|
18
|
+
from planar.workflows.decorators import workflow
|
19
|
+
from planar.workflows.execution import execute
|
20
|
+
from planar.workflows.models import StepType, Workflow, WorkflowStatus, WorkflowStep
|
21
|
+
from planar.workflows.step_core import Suspend
|
22
|
+
|
23
|
+
|
24
|
+
# Test data models
|
25
|
+
class ExpenseRequest(BaseModel):
|
26
|
+
"""An expense request submitted by an employee."""
|
27
|
+
|
28
|
+
request_id: str = Field(description="Unique identifier for the request")
|
29
|
+
amount: float = Field(description="Amount requested in dollars")
|
30
|
+
requester: str = Field(description="Name of the person requesting")
|
31
|
+
department: str = Field(description="Department the requester belongs to")
|
32
|
+
purpose: str = Field(description="Purpose of the expense")
|
33
|
+
|
34
|
+
|
35
|
+
class ExpenseDecision(BaseModel):
|
36
|
+
"""A decision made by a human approver on an expense request."""
|
37
|
+
|
38
|
+
approved: bool = Field(description="Whether the expense is approved")
|
39
|
+
approved_amount: float = Field(
|
40
|
+
description="Amount approved (may be less than requested)"
|
41
|
+
)
|
42
|
+
notes: str = Field(description="Explanation for decision", default="")
|
43
|
+
|
44
|
+
|
45
|
+
class HumanResponse(BaseModel):
|
46
|
+
response: str = Field(description="A message from the human")
|
47
|
+
|
48
|
+
|
49
|
+
@pytest.fixture
|
50
|
+
def expense_approval():
|
51
|
+
"""Returns a Human task definition for expense approval testing."""
|
52
|
+
return Human(
|
53
|
+
name="expense_approval",
|
54
|
+
title="Expense Approval",
|
55
|
+
description="Review expense request and approve, adjust, or reject",
|
56
|
+
input_type=ExpenseRequest,
|
57
|
+
output_type=ExpenseDecision,
|
58
|
+
timeout=Timeout(timedelta(hours=24)),
|
59
|
+
)
|
60
|
+
|
61
|
+
|
62
|
+
@pytest.fixture
|
63
|
+
def expense_approval_no_input():
|
64
|
+
"""Returns a Human task definition for expense approval testing."""
|
65
|
+
return Human(
|
66
|
+
name="expense_approval_no_input",
|
67
|
+
title="Expense Approval (No Input)",
|
68
|
+
description="Review expense request and approve, adjust, or reject",
|
69
|
+
output_type=ExpenseDecision,
|
70
|
+
timeout=Timeout(timedelta(hours=24)),
|
71
|
+
)
|
72
|
+
|
73
|
+
|
74
|
+
# Create a fixture for sample expense request data
|
75
|
+
@pytest.fixture
|
76
|
+
def expense_request_data():
|
77
|
+
"""Returns sample expense request data for testing."""
|
78
|
+
return {
|
79
|
+
"request_id": "EXP-123",
|
80
|
+
"amount": 750.00,
|
81
|
+
"requester": "Jane Smith",
|
82
|
+
"department": "Engineering",
|
83
|
+
"purpose": "Conference travel expenses",
|
84
|
+
}
|
85
|
+
|
86
|
+
|
87
|
+
async def test_human_initialization():
|
88
|
+
"""Test that the Human class initializes with correct parameters."""
|
89
|
+
human = Human(
|
90
|
+
name="test_human",
|
91
|
+
title="Test Human Task",
|
92
|
+
output_type=ExpenseDecision,
|
93
|
+
description="Test description",
|
94
|
+
input_type=ExpenseRequest,
|
95
|
+
timeout=Timeout(timedelta(hours=1)),
|
96
|
+
)
|
97
|
+
|
98
|
+
# Verify initialization
|
99
|
+
assert human.name == "test_human"
|
100
|
+
assert human.title == "Test Human Task"
|
101
|
+
assert human.description == "Test description"
|
102
|
+
assert human.input_type == ExpenseRequest
|
103
|
+
assert human.output_type == ExpenseDecision
|
104
|
+
assert human.timeout is not None
|
105
|
+
assert human.timeout.get_seconds() == 3600 # 1 hour in seconds
|
106
|
+
|
107
|
+
|
108
|
+
async def test_human_initialization_validation():
|
109
|
+
"""Test that the Human class validates output_type is a Pydantic model."""
|
110
|
+
with pytest.raises(ValueError, match="output_type must be a Pydantic model"):
|
111
|
+
Human(
|
112
|
+
name="test_human",
|
113
|
+
title="Test Human Task",
|
114
|
+
# Invalid: not a Pydantic model
|
115
|
+
output_type=str, # type: ignore
|
116
|
+
)
|
117
|
+
|
118
|
+
|
119
|
+
async def test_human_initialization_validation_no_input(session: AsyncSession):
|
120
|
+
human_no_input = Human(
|
121
|
+
name="test_human",
|
122
|
+
title="Test Human Task",
|
123
|
+
output_type=HumanResponse,
|
124
|
+
)
|
125
|
+
|
126
|
+
@workflow()
|
127
|
+
async def expense_workflow():
|
128
|
+
result = await human_no_input(message="Hello, world!")
|
129
|
+
return result.output.response
|
130
|
+
|
131
|
+
wf = await expense_workflow.start()
|
132
|
+
result = await execute(wf)
|
133
|
+
assert isinstance(result, Suspend)
|
134
|
+
|
135
|
+
steps = (
|
136
|
+
await session.exec(select(WorkflowStep).order_by(col(WorkflowStep.step_id)))
|
137
|
+
).all()
|
138
|
+
assert len(steps) == 3
|
139
|
+
assert "Create Human Task" in [s.display_name for s in steps]
|
140
|
+
assert "Wait for event" in [s.display_name for s in steps]
|
141
|
+
|
142
|
+
assert StepType.HUMAN_IN_THE_LOOP in [s.step_type for s in steps]
|
143
|
+
assert steps[0].args == [None, "Hello, world!", None]
|
144
|
+
|
145
|
+
# Get HumanTask from database
|
146
|
+
human_task = (await session.exec(select(HumanTask))).one()
|
147
|
+
assert human_task is not None
|
148
|
+
assert human_task.name == "test_human"
|
149
|
+
assert human_task.title == "Test Human Task"
|
150
|
+
assert human_task.output_schema == HumanResponse.model_json_schema()
|
151
|
+
assert human_task.input_schema is None
|
152
|
+
assert human_task.message == "Hello, world!"
|
153
|
+
|
154
|
+
await complete_human_task(human_task.id, {"response": "Approved"})
|
155
|
+
result = await execute(wf)
|
156
|
+
assert result == "Approved"
|
157
|
+
|
158
|
+
|
159
|
+
async def test_human_basic_workflow(
|
160
|
+
session: AsyncSession, expense_approval, expense_request_data
|
161
|
+
):
|
162
|
+
"""Test that a Human step can be used in a workflow with input data."""
|
163
|
+
|
164
|
+
@workflow()
|
165
|
+
async def expense_workflow(request_data: dict):
|
166
|
+
request = ExpenseRequest(**request_data)
|
167
|
+
result = await expense_approval(request)
|
168
|
+
# Add a suspend to ensure the workflow correctly
|
169
|
+
# deserializes the result of human task on subsequent executions
|
170
|
+
await suspend(interval=timedelta(seconds=0))
|
171
|
+
return {
|
172
|
+
"request_id": request.request_id,
|
173
|
+
"approved": result.output.approved,
|
174
|
+
"amount": result.output.approved_amount,
|
175
|
+
"notes": result.output.notes,
|
176
|
+
}
|
177
|
+
|
178
|
+
# Start the workflow and run until it suspends
|
179
|
+
wf = await expense_workflow.start(expense_request_data)
|
180
|
+
result = await execute(wf)
|
181
|
+
assert isinstance(result, Suspend)
|
182
|
+
|
183
|
+
# Query workflows and steps from the database
|
184
|
+
updated_wf = await session.get(Workflow, wf.id)
|
185
|
+
assert updated_wf is not None
|
186
|
+
assert updated_wf.status == WorkflowStatus.PENDING
|
187
|
+
assert updated_wf.waiting_for_event is not None
|
188
|
+
assert "human_task_completed:" in updated_wf.waiting_for_event
|
189
|
+
|
190
|
+
steps = (
|
191
|
+
await session.exec(select(WorkflowStep).order_by(col(WorkflowStep.step_id)))
|
192
|
+
).all()
|
193
|
+
assert len(steps) == 4
|
194
|
+
assert "expense_approval" in [s.display_name for s in steps]
|
195
|
+
|
196
|
+
# Get HumanTask from database and verify fields
|
197
|
+
human_task = (await session.exec(select(HumanTask))).one()
|
198
|
+
assert human_task is not None
|
199
|
+
assert human_task.name == "expense_approval"
|
200
|
+
assert human_task.title == "Expense Approval"
|
201
|
+
assert human_task.workflow_id == wf.id
|
202
|
+
assert human_task.status == HumanTaskStatus.PENDING
|
203
|
+
assert human_task.input_schema == ExpenseRequest.model_json_schema()
|
204
|
+
assert human_task.input_data is not None
|
205
|
+
assert human_task.input_data["request_id"] == "EXP-123"
|
206
|
+
assert human_task.input_data["amount"] == 750.00
|
207
|
+
assert human_task.message is None
|
208
|
+
assert human_task.output_schema == ExpenseDecision.model_json_schema()
|
209
|
+
assert human_task.output_data is None
|
210
|
+
|
211
|
+
# Complete the human task
|
212
|
+
output_data = {
|
213
|
+
"approved": True,
|
214
|
+
"approved_amount": 700.00,
|
215
|
+
"notes": "Approved with reduced amount",
|
216
|
+
}
|
217
|
+
await complete_human_task(human_task.id, output_data, completed_by="test_user")
|
218
|
+
|
219
|
+
# Check the human task was updated correctly
|
220
|
+
await session.refresh(human_task)
|
221
|
+
assert human_task.status == HumanTaskStatus.COMPLETED
|
222
|
+
assert human_task.output_data == output_data
|
223
|
+
assert human_task.completed_by == "test_user"
|
224
|
+
assert human_task.completed_at is not None
|
225
|
+
|
226
|
+
# Resume and complete the workflow
|
227
|
+
result = await execute(wf)
|
228
|
+
assert isinstance(result, Suspend)
|
229
|
+
result = await execute(wf)
|
230
|
+
|
231
|
+
# Verify workflow completed successfully with expected result
|
232
|
+
updated_wf = await session.get(Workflow, wf.id)
|
233
|
+
assert updated_wf is not None
|
234
|
+
assert updated_wf.status == WorkflowStatus.SUCCEEDED
|
235
|
+
expected_result = {
|
236
|
+
"request_id": expense_request_data["request_id"],
|
237
|
+
"approved": output_data["approved"],
|
238
|
+
"amount": output_data["approved_amount"],
|
239
|
+
"notes": output_data["notes"],
|
240
|
+
}
|
241
|
+
assert updated_wf.result == expected_result
|
242
|
+
|
243
|
+
|
244
|
+
async def test_human_task_completion_validation(session: AsyncSession):
|
245
|
+
"""Test validation when completing a human task."""
|
246
|
+
|
247
|
+
workflow = Workflow(
|
248
|
+
function_name="test_workflow",
|
249
|
+
status=WorkflowStatus.PENDING,
|
250
|
+
args=[],
|
251
|
+
kwargs={},
|
252
|
+
)
|
253
|
+
session.add(workflow)
|
254
|
+
await session.commit()
|
255
|
+
# Create a human task
|
256
|
+
task = HumanTask(
|
257
|
+
id=uuid4(),
|
258
|
+
name="test_task",
|
259
|
+
title="Test Task",
|
260
|
+
workflow_id=workflow.id,
|
261
|
+
workflow_name="test_workflow",
|
262
|
+
output_schema=ExpenseDecision.model_json_schema(),
|
263
|
+
status=HumanTaskStatus.PENDING,
|
264
|
+
)
|
265
|
+
|
266
|
+
session.add(task)
|
267
|
+
await session.commit()
|
268
|
+
task_id = task.id
|
269
|
+
|
270
|
+
# Test completing a non-existent task
|
271
|
+
with pytest.raises(ValueError, match="not found"):
|
272
|
+
await complete_human_task(UUID("00000000-0000-0000-0000-000000000000"), {})
|
273
|
+
|
274
|
+
# Test completing a task that's not in pending state
|
275
|
+
task.status = HumanTaskStatus.CANCELLED
|
276
|
+
session.add(task)
|
277
|
+
await session.commit()
|
278
|
+
|
279
|
+
with pytest.raises(ValueError, match="not pending"):
|
280
|
+
await complete_human_task(task_id, {})
|
281
|
+
|
282
|
+
# Reset to pending for the next test
|
283
|
+
task.status = HumanTaskStatus.PENDING
|
284
|
+
session.add(task)
|
285
|
+
await session.commit()
|
286
|
+
|
287
|
+
# Mock emit_event for normal completion test
|
288
|
+
with patch("planar.workflows.events.emit_event", AsyncMock()):
|
289
|
+
# Complete with valid data
|
290
|
+
output_data = {
|
291
|
+
"approved": True,
|
292
|
+
"approved_amount": 150.00,
|
293
|
+
"notes": "Approved",
|
294
|
+
}
|
295
|
+
|
296
|
+
await complete_human_task(task_id, output_data)
|
297
|
+
|
298
|
+
# Verify task state
|
299
|
+
await session.refresh(task)
|
300
|
+
assert task.status == HumanTaskStatus.COMPLETED
|
301
|
+
assert task.output_data == output_data
|
302
|
+
|
303
|
+
|
304
|
+
async def test_timeout_class():
|
305
|
+
"""Test the Timeout helper class functionality."""
|
306
|
+
# Test with various durations
|
307
|
+
one_hour = Timeout(timedelta(hours=1))
|
308
|
+
assert one_hour.get_seconds() == 3600
|
309
|
+
assert one_hour.get_timedelta() == timedelta(hours=1)
|
310
|
+
|
311
|
+
five_minutes = Timeout(timedelta(minutes=5))
|
312
|
+
assert five_minutes.get_seconds() == 300
|
313
|
+
assert five_minutes.get_timedelta() == timedelta(minutes=5)
|
314
|
+
|
315
|
+
|
316
|
+
async def test_human_task_with_suggested_data(session: AsyncSession):
|
317
|
+
"""Test that a Human step can be used with suggested_data."""
|
318
|
+
human_with_suggestions = Human(
|
319
|
+
name="test_human_suggestions",
|
320
|
+
title="Test Human Task with Suggestions",
|
321
|
+
output_type=ExpenseDecision,
|
322
|
+
)
|
323
|
+
|
324
|
+
@workflow()
|
325
|
+
async def expense_workflow():
|
326
|
+
result = await human_with_suggestions(
|
327
|
+
message="Please review the expense",
|
328
|
+
suggested_data=ExpenseDecision(
|
329
|
+
approved=True,
|
330
|
+
approved_amount=500.0,
|
331
|
+
notes="Pre-approved amount",
|
332
|
+
),
|
333
|
+
)
|
334
|
+
return result.output.notes
|
335
|
+
|
336
|
+
wf = await expense_workflow.start()
|
337
|
+
result = await execute(wf)
|
338
|
+
assert isinstance(result, Suspend)
|
339
|
+
|
340
|
+
# Get HumanTask from database and verify suggested_data is stored
|
341
|
+
human_task = (await session.exec(select(HumanTask))).one()
|
342
|
+
assert human_task is not None
|
343
|
+
assert human_task.name == "test_human_suggestions"
|
344
|
+
assert human_task.suggested_data is not None
|
345
|
+
assert human_task.suggested_data["approved"] is True
|
346
|
+
assert human_task.suggested_data["approved_amount"] == 500.0
|
347
|
+
assert human_task.suggested_data["notes"] == "Pre-approved amount"
|
348
|
+
|
349
|
+
# Complete the human task
|
350
|
+
await complete_human_task(
|
351
|
+
human_task.id,
|
352
|
+
{"approved": False, "approved_amount": 0.0, "notes": "Rejected after review"},
|
353
|
+
)
|
354
|
+
|
355
|
+
result = await execute(wf)
|
356
|
+
assert result == "Rejected after review"
|
357
|
+
|
358
|
+
|
359
|
+
async def test_deadline_calculation():
|
360
|
+
"""Test that deadlines are calculated correctly based on timeout."""
|
361
|
+
# Create a human task with a deadline
|
362
|
+
with patch("planar.human.human.utc_now") as mock_datetime:
|
363
|
+
# Mock the current time
|
364
|
+
now = datetime(2025, 1, 1, 12, 0, 0)
|
365
|
+
mock_datetime.return_value = now
|
366
|
+
|
367
|
+
# Calculate deadlines with different timeouts
|
368
|
+
one_hour_timeout = Human(
|
369
|
+
name="one_hour",
|
370
|
+
title="One Hour Timeout",
|
371
|
+
output_type=ExpenseDecision,
|
372
|
+
timeout=Timeout(timedelta(hours=1)),
|
373
|
+
)
|
374
|
+
|
375
|
+
deadline = one_hour_timeout._calculate_deadline()
|
376
|
+
assert deadline == datetime(2025, 1, 1, 13, 0, 0)
|
377
|
+
|
378
|
+
# Test with no timeout
|
379
|
+
no_timeout = Human(
|
380
|
+
name="no_timeout",
|
381
|
+
title="No Timeout",
|
382
|
+
output_type=ExpenseDecision,
|
383
|
+
)
|
384
|
+
|
385
|
+
assert no_timeout._calculate_deadline() is None
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
import threading
|
5
|
+
|
6
|
+
from planar.workflows.context import get_context, in_context
|
7
|
+
|
8
|
+
from .context import get_context_metadata
|
9
|
+
|
10
|
+
pid = os.getpid()
|
11
|
+
|
12
|
+
|
13
|
+
def _in_event_loop_task() -> bool:
|
14
|
+
"""
|
15
|
+
Checks if the current thread is the main thread and an asyncio event loop is running.
|
16
|
+
"""
|
17
|
+
try:
|
18
|
+
return (
|
19
|
+
threading.main_thread() == threading.current_thread()
|
20
|
+
and asyncio.get_running_loop() is not None
|
21
|
+
)
|
22
|
+
except RuntimeError:
|
23
|
+
return False
|
24
|
+
|
25
|
+
|
26
|
+
class ExtraAttributesFilter(logging.Filter):
|
27
|
+
"""
|
28
|
+
A logging filter that adds extra contextual attributes to log records.
|
29
|
+
|
30
|
+
Attributes added:
|
31
|
+
- pid: The process ID.
|
32
|
+
- workflow_id: The ID of the current workflow, if in a workflow context.
|
33
|
+
- step_id: The ID of the current step, if in a workflow context.
|
34
|
+
- task_name: The name of the current asyncio task.
|
35
|
+
- Other attributes from the logging context.
|
36
|
+
"""
|
37
|
+
|
38
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
39
|
+
"""
|
40
|
+
Adds extra attributes to the log record.
|
41
|
+
"""
|
42
|
+
setattr(record, "pid", pid)
|
43
|
+
|
44
|
+
context_metadata = get_context_metadata()
|
45
|
+
for key, value in context_metadata.items():
|
46
|
+
setattr(record, key, value)
|
47
|
+
|
48
|
+
if _in_event_loop_task():
|
49
|
+
if in_context():
|
50
|
+
ctx = get_context()
|
51
|
+
setattr(record, "workflow_id", str(ctx.workflow_id))
|
52
|
+
setattr(record, "step_id", ctx.current_step_id)
|
53
|
+
|
54
|
+
return True
|
@@ -0,0 +1,14 @@
|
|
1
|
+
from contextvars import ContextVar
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
context_metadata: ContextVar[dict[str, Any]] = ContextVar("context_metadata")
|
5
|
+
|
6
|
+
|
7
|
+
def set_context_metadata(key: str, value: Any):
|
8
|
+
if not context_metadata.get(False):
|
9
|
+
context_metadata.set({})
|
10
|
+
context_metadata.get()[key] = value
|
11
|
+
|
12
|
+
|
13
|
+
def get_context_metadata() -> dict[str, Any]:
|
14
|
+
return context_metadata.get({})
|
@@ -0,0 +1,113 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
from typing import Any, Dict
|
4
|
+
|
5
|
+
from pygments import formatters, highlight, lexers
|
6
|
+
|
7
|
+
# A set of standard LogRecord attributes that should not be included in the extra fields.
|
8
|
+
# Copied from logging/__init__.py and added a few more that are sometimes present.
|
9
|
+
STANDARD_LOG_RECORD_ATTRS = {
|
10
|
+
"args",
|
11
|
+
"asctime",
|
12
|
+
"created",
|
13
|
+
"color_message",
|
14
|
+
"exc_info",
|
15
|
+
"exc_text",
|
16
|
+
"filename",
|
17
|
+
"funcName",
|
18
|
+
"levelname",
|
19
|
+
"levelno",
|
20
|
+
"lineno",
|
21
|
+
"message",
|
22
|
+
"module",
|
23
|
+
"msecs",
|
24
|
+
"msg",
|
25
|
+
"name",
|
26
|
+
"pathname",
|
27
|
+
"pid",
|
28
|
+
"process",
|
29
|
+
"processName",
|
30
|
+
"relativeCreated",
|
31
|
+
"stack_info",
|
32
|
+
"thread",
|
33
|
+
"threadName",
|
34
|
+
"taskName",
|
35
|
+
}
|
36
|
+
|
37
|
+
|
38
|
+
COLORS: Dict[int, str] = {
|
39
|
+
logging.DEBUG: "\033[94m", # Blue
|
40
|
+
logging.INFO: "\033[92m", # Green
|
41
|
+
logging.WARNING: "\033[93m", # Yellow
|
42
|
+
logging.ERROR: "\033[91m", # Red
|
43
|
+
logging.CRITICAL: "\033[91m\033[1m", # Bold Red
|
44
|
+
}
|
45
|
+
RESET = "\033[0m"
|
46
|
+
DARK_GRAY = "\033[90m"
|
47
|
+
|
48
|
+
|
49
|
+
def json_print(value: Any, use_colors: bool = False) -> str:
|
50
|
+
if not isinstance(value, (dict, list, int, bool, float, str)):
|
51
|
+
value = str(value)
|
52
|
+
stringified = json.dumps(value)
|
53
|
+
if use_colors:
|
54
|
+
lexer = lexers.JsonLexer()
|
55
|
+
formatter = formatters.TerminalFormatter()
|
56
|
+
return highlight(stringified, lexer, formatter)
|
57
|
+
else:
|
58
|
+
return stringified
|
59
|
+
|
60
|
+
|
61
|
+
def dictionary_print(value: Dict[str, Any], use_colors: bool = False) -> str:
|
62
|
+
result = []
|
63
|
+
for key, val in value.items():
|
64
|
+
val_str = json_print(val, use_colors).strip()
|
65
|
+
result.append(f"{key}={val_str}")
|
66
|
+
return ",".join(result)
|
67
|
+
|
68
|
+
|
69
|
+
class StructuredFormatter(logging.Formatter):
|
70
|
+
"""
|
71
|
+
A logging formatter that formats logs in a structured way with key-value pairs,
|
72
|
+
and adds color to log levels when connected to a TTY.
|
73
|
+
"""
|
74
|
+
|
75
|
+
def __init__(self, use_colors: bool = False):
|
76
|
+
super().__init__()
|
77
|
+
self.use_colors = use_colors
|
78
|
+
|
79
|
+
def format(self, record: logging.LogRecord) -> str:
|
80
|
+
message = record.getMessage()
|
81
|
+
levelname = record.levelname
|
82
|
+
|
83
|
+
padding_len = 10 - len(levelname)
|
84
|
+
padding_len = max(1, padding_len)
|
85
|
+
padded_colon = f"{':':<{padding_len}}"
|
86
|
+
|
87
|
+
if self.use_colors:
|
88
|
+
color = COLORS.get(record.levelno, "")
|
89
|
+
levelname = f"{color}{levelname}{RESET}"
|
90
|
+
record_name = f"{DARK_GRAY}{record.name}{RESET}"
|
91
|
+
else:
|
92
|
+
record_name = record.name
|
93
|
+
|
94
|
+
extra_attrs = self._format_extra_attrs(record)
|
95
|
+
|
96
|
+
log_message = f"{levelname}{padded_colon}{message} [{record_name}]"
|
97
|
+
if extra_attrs:
|
98
|
+
log_message += f" [{extra_attrs}]"
|
99
|
+
|
100
|
+
if record.exc_info:
|
101
|
+
log_message += "\n" + self.formatException(record.exc_info)
|
102
|
+
|
103
|
+
return log_message
|
104
|
+
|
105
|
+
def _format_extra_attrs(self, record: logging.LogRecord) -> str:
|
106
|
+
extra = {
|
107
|
+
(key[1:] if key.startswith("$") else key): value
|
108
|
+
for key, value in record.__dict__.items()
|
109
|
+
if key not in STANDARD_LOG_RECORD_ATTRS
|
110
|
+
}
|
111
|
+
if not extra:
|
112
|
+
return ""
|
113
|
+
return dictionary_print(extra, self.use_colors)
|