mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-23 00:58:10 +00:00
Compare commits
3 Commits
fix/file-t
...
declarativ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e220b1617 | ||
|
|
0391febc6c | ||
|
|
4cbfbdb232 |
16
conftest.py
16
conftest.py
@@ -134,17 +134,21 @@ def bedrock_host_matcher(r1: Request, r2: Request) -> bool: # type: ignore[no-a
|
||||
)
|
||||
|
||||
|
||||
def _patched_make_vcr_request(httpx_request: Any, **kwargs: Any) -> Any:
|
||||
def _patched_make_vcr_request(
|
||||
httpx_request: Any, real_request_body: Any = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Patched version of VCR's _make_vcr_request that handles binary content.
|
||||
|
||||
The original implementation fails on binary request bodies (like file uploads)
|
||||
because it assumes all content can be decoded as UTF-8.
|
||||
"""
|
||||
raw_body = httpx_request.read()
|
||||
try:
|
||||
body = raw_body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body = base64.b64encode(raw_body).decode("ascii")
|
||||
raw_body = real_request_body if real_request_body is not None else httpx_request.read()
|
||||
body: Any = raw_body
|
||||
if isinstance(raw_body, bytes):
|
||||
try:
|
||||
body = raw_body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body = base64.b64encode(raw_body).decode("ascii")
|
||||
uri = str(httpx_request.url)
|
||||
headers = dict(httpx_request.headers)
|
||||
return Request(httpx_request.method, uri, body, headers)
|
||||
|
||||
@@ -42,25 +42,6 @@ print(result)
|
||||
- `content`: The content to write into the file.
|
||||
- `directory` (optional): The path to the directory where the file will be created. Defaults to the current directory (`.`). If the directory does not exist, it will be created.
|
||||
|
||||
## Path confinement
|
||||
|
||||
Because `filename` and `directory` may be supplied at runtime by an agent acting on untrusted content, `FileWriterTool` confines writes to an **allow-listed set of root directories**. The resolved target (after expanding symlinks and `..`) must fall inside one of these roots or the write is rejected — a `directory` argument pointing outside them (e.g. `~/.ssh`, `/etc`) no longer grants write access.
|
||||
|
||||
The allow-list is, by default, the current working directory. You can extend it for deployments that legitimately write elsewhere:
|
||||
|
||||
- `CREWAI_TOOLS_ALLOWED_DIRS` — one or more additional root directories, separated by the OS path separator (`:` on Linux/macOS, `;` on Windows).
|
||||
|
||||
```shell
|
||||
# Allow writes under /data and /workspace in addition to the cwd
|
||||
export CREWAI_TOOLS_ALLOWED_DIRS="/data:/workspace"
|
||||
```
|
||||
|
||||
<Warning>
|
||||
If the process runs with its working directory set to the filesystem root (`/`) — common in containers started without a `WORKDIR` — the tool will **not** fall back to allow-listing the entire filesystem. Writes fail with a `ValueError` until you set `CREWAI_TOOLS_ALLOWED_DIRS` to an explicit directory. Set a `WORKDIR` (or the env var) in such deployments.
|
||||
</Warning>
|
||||
|
||||
The `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` escape hatch disables path validation entirely. It is intended only for trusted local development and should not be set in any environment that runs agent-generated or otherwise untrusted instructions.
|
||||
|
||||
## Conclusion
|
||||
|
||||
By integrating the `FileWriterTool` into your crews, the agents can reliably write content to files across different operating systems.
|
||||
|
||||
@@ -40,12 +40,12 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any:
|
||||
return _replay_task_command(*args, **kwargs)
|
||||
|
||||
|
||||
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.run_flow_definition import (
|
||||
run_flow_definition as _run_flow_definition,
|
||||
def run_declarative_flow_in_project_env(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.run_declarative_flow import (
|
||||
run_declarative_flow_in_project_env as _run_declarative_flow_in_project_env,
|
||||
)
|
||||
|
||||
return _run_flow_definition(*args, **kwargs)
|
||||
return _run_declarative_flow_in_project_env(*args, **kwargs)
|
||||
|
||||
|
||||
def run_crew(*args: Any, **kwargs: Any) -> Any:
|
||||
@@ -155,12 +155,18 @@ def uv(uv_args: tuple[str, ...]) -> None:
|
||||
is_flag=True,
|
||||
help="Use classic Python/YAML project structure instead of JSON",
|
||||
)
|
||||
@click.option(
|
||||
"--declarative",
|
||||
is_flag=True,
|
||||
help="Create a declarative Flow project instead of a Python Flow project",
|
||||
)
|
||||
def create(
|
||||
type: str | None,
|
||||
name: str | None,
|
||||
provider: str | None,
|
||||
skip_provider: bool = False,
|
||||
classic: bool = False,
|
||||
declarative: bool = False,
|
||||
) -> None:
|
||||
"""Create a new crew, or flow."""
|
||||
dmn_mode = is_dmn_mode_enabled()
|
||||
@@ -194,6 +200,8 @@ def create(
|
||||
if dmn_mode:
|
||||
skip_provider = True
|
||||
if type == "crew":
|
||||
if declarative:
|
||||
raise click.UsageError("--declarative can only be used with flow projects")
|
||||
if classic:
|
||||
from crewai_cli.create_crew import create_crew
|
||||
|
||||
@@ -205,7 +213,7 @@ def create(
|
||||
elif type == "flow":
|
||||
from crewai_cli.create_flow import create_flow
|
||||
|
||||
create_flow(name)
|
||||
create_flow(name, declarative=declarative)
|
||||
else:
|
||||
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
|
||||
|
||||
@@ -512,16 +520,13 @@ def install(context: click.Context) -> None:
|
||||
"--definition",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental: path to a Flow Definition YAML/JSON file, "
|
||||
"or an inline YAML/JSON string."
|
||||
),
|
||||
help="Path to a declarative Flow YAML/JSON file.",
|
||||
)
|
||||
@click.option(
|
||||
"--inputs",
|
||||
type=str,
|
||||
default=None,
|
||||
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
|
||||
help='JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
|
||||
)
|
||||
def run(
|
||||
trained_agents_file: str | None,
|
||||
@@ -533,14 +538,9 @@ def run(
|
||||
raise click.UsageError("--inputs requires --definition")
|
||||
|
||||
if definition is not None:
|
||||
click.secho(
|
||||
"Warning: `crewai run --definition` is experimental and may change without notice.",
|
||||
fg="yellow",
|
||||
)
|
||||
run_flow_definition(definition=definition, inputs=inputs)
|
||||
return
|
||||
|
||||
run_crew(trained_agents_file=trained_agents_file)
|
||||
run_declarative_flow_in_project_env(definition=definition, inputs=inputs)
|
||||
else:
|
||||
run_crew(trained_agents_file=trained_agents_file)
|
||||
|
||||
|
||||
@crewai.command()
|
||||
|
||||
@@ -5,7 +5,10 @@ import click
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
|
||||
def create_flow(name: str) -> None:
|
||||
DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
|
||||
|
||||
|
||||
def create_flow(name: str, *, declarative: bool = False) -> None:
|
||||
"""Create a new flow."""
|
||||
folder_name = name.replace(" ", "_").replace("-", "_").lower()
|
||||
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
|
||||
@@ -20,6 +23,17 @@ def create_flow(name: str) -> None:
|
||||
telemetry = Telemetry()
|
||||
telemetry.flow_creation_span(class_name)
|
||||
|
||||
if declarative:
|
||||
_create_declarative_flow(name, class_name, folder_name, project_root)
|
||||
else:
|
||||
_create_python_flow(name, class_name, folder_name, project_root)
|
||||
|
||||
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
|
||||
|
||||
|
||||
def _create_python_flow(
|
||||
name: str, class_name: str, folder_name: str, project_root: Path
|
||||
) -> None:
|
||||
(project_root / "src" / folder_name).mkdir(parents=True)
|
||||
(project_root / "src" / folder_name / "crews").mkdir(parents=True)
|
||||
(project_root / "src" / folder_name / "tools").mkdir(parents=True)
|
||||
@@ -92,4 +106,33 @@ def create_flow(name: str) -> None:
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
|
||||
|
||||
def _create_declarative_flow(
|
||||
name: str, class_name: str, folder_name: str, project_root: Path
|
||||
) -> None:
|
||||
project_root.mkdir(parents=True)
|
||||
for folder in DECLARATIVE_FLOW_FOLDERS:
|
||||
(project_root / folder).mkdir()
|
||||
|
||||
package_dir = Path(__file__).parent
|
||||
templates_dir = package_dir / "templates" / "declarative_flow"
|
||||
|
||||
agents_md_src = package_dir / "templates" / "AGENTS.md"
|
||||
if agents_md_src.exists():
|
||||
shutil.copy2(agents_md_src, project_root / "AGENTS.md")
|
||||
|
||||
for src_file in templates_dir.rglob("*"):
|
||||
if not src_file.is_file():
|
||||
continue
|
||||
|
||||
dst_file = project_root / src_file.relative_to(templates_dir)
|
||||
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = src_file.read_text(encoding="utf-8")
|
||||
content = content.replace("{{name}}", name)
|
||||
content = content.replace("{{flow_name}}", class_name)
|
||||
content = content.replace("{{folder_name}}", folder_name)
|
||||
dst_file.write_text(content, encoding="utf-8")
|
||||
|
||||
(project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")
|
||||
for folder in ("knowledge", "skills"):
|
||||
(project_root / folder / ".gitkeep").write_text("", encoding="utf-8")
|
||||
|
||||
@@ -680,7 +680,7 @@ def _default_agents_and_tasks(
|
||||
]
|
||||
crew_settings = {
|
||||
"process": "sequential",
|
||||
"memory": False,
|
||||
"memory": True,
|
||||
"inputs": {},
|
||||
}
|
||||
return agents, tasks, crew_settings
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
import zipfile
|
||||
|
||||
from crewai_cli import git
|
||||
from crewai_cli.deploy.validate import normalize_package_name
|
||||
from crewai_cli.utils import parse_toml
|
||||
|
||||
|
||||
_EXCLUDED_DIRS = {
|
||||
@@ -38,8 +34,6 @@ _EXCLUDED_SUFFIXES = {
|
||||
".pyc",
|
||||
".pyo",
|
||||
}
|
||||
_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
|
||||
_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
|
||||
|
||||
|
||||
def create_project_zip(
|
||||
@@ -143,267 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
|
||||
destination = staging_root / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
if _is_json_crew_project(staging_root):
|
||||
_add_json_crew_deploy_wrapper(staging_root)
|
||||
except Exception:
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
raise
|
||||
return staging_root
|
||||
|
||||
|
||||
def _is_json_crew_project(root: Path) -> bool:
|
||||
"""Return True for JSON crew projects that need a Python deploy wrapper."""
|
||||
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
|
||||
return False
|
||||
|
||||
project = _read_pyproject(root)
|
||||
tool_config = project.get("tool") or {}
|
||||
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
|
||||
declared_type = (
|
||||
crewai_config.get("type") if isinstance(crewai_config, dict) else None
|
||||
)
|
||||
if declared_type == "flow":
|
||||
return False
|
||||
|
||||
package_name = _package_name(root)
|
||||
if package_name is None:
|
||||
raise ValueError(
|
||||
"Could not derive a valid Python package name from [project].name."
|
||||
)
|
||||
|
||||
return not (root / "src" / package_name / "crew.py").is_file()
|
||||
|
||||
|
||||
def _read_pyproject(root: Path) -> dict[str, Any]:
|
||||
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
pyproject = parse_toml(pyproject_path.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return pyproject if isinstance(pyproject, dict) else {}
|
||||
|
||||
|
||||
def _package_name(root: Path) -> str | None:
|
||||
"""Return the normalized Python package name for the project."""
|
||||
project = _read_pyproject(root).get("project")
|
||||
if not isinstance(project, dict):
|
||||
return None
|
||||
|
||||
name = project.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
|
||||
package_name = normalize_package_name(name)
|
||||
return package_name or None
|
||||
|
||||
|
||||
def _class_name(package_name: str) -> str:
|
||||
"""Return the generated wrapper class name for a package."""
|
||||
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
|
||||
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
|
||||
if not class_name:
|
||||
return "JsonCrew"
|
||||
if class_name[0].isdigit():
|
||||
return f"Crew{class_name}"
|
||||
return class_name
|
||||
|
||||
|
||||
def _add_json_crew_deploy_wrapper(root: Path) -> None:
|
||||
"""Add Python wrapper files required to deploy a JSON crew project."""
|
||||
package_name = _package_name(root)
|
||||
if package_name is None:
|
||||
raise ValueError(
|
||||
"Could not derive a valid Python package name from [project].name."
|
||||
)
|
||||
|
||||
package_dir = root / "src" / package_name
|
||||
config_dir = package_dir / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class_name = _class_name(package_name)
|
||||
crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
|
||||
|
||||
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
(config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(package_dir / "crew.py").write_text(
|
||||
_json_crew_py(class_name, crew_filename),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package_dir / "main.py").write_text(
|
||||
_json_main_py(package_name, class_name),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_ensure_project_scripts(root, package_name)
|
||||
|
||||
|
||||
def _json_crew_py(class_name: str, crew_filename: str) -> str:
|
||||
"""Render the generated crew.py module for a JSON crew."""
|
||||
return f'''from pathlib import Path
|
||||
|
||||
from crewai import Crew
|
||||
from crewai.project import CrewBase, crew
|
||||
from crewai.project.crew_loader import load_crew
|
||||
|
||||
|
||||
def _crew_path() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "{crew_filename}"
|
||||
|
||||
|
||||
@CrewBase
|
||||
class {class_name}:
|
||||
"""Compatibility wrapper for a JSON-defined CrewAI project."""
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
crew_instance, default_inputs = load_crew(_crew_path())
|
||||
self.default_inputs = default_inputs
|
||||
return crew_instance
|
||||
'''
|
||||
|
||||
|
||||
def _json_main_py(package_name: str, class_name: str) -> str:
|
||||
"""Render the generated main.py entrypoints for a JSON crew."""
|
||||
return f"""#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
|
||||
from {package_name}.crew import {class_name}
|
||||
|
||||
|
||||
def _load():
|
||||
wrapper = {class_name}()
|
||||
crew = wrapper.crew()
|
||||
return crew, getattr(wrapper, "default_inputs", {{}})
|
||||
|
||||
|
||||
def run():
|
||||
crew, inputs = _load()
|
||||
return crew.kickoff(inputs=inputs)
|
||||
|
||||
|
||||
def train():
|
||||
crew, inputs = _load()
|
||||
return crew.train(
|
||||
n_iterations=int(sys.argv[1]),
|
||||
filename=sys.argv[2],
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
|
||||
def replay():
|
||||
crew, _ = _load()
|
||||
return crew.replay(task_id=sys.argv[1])
|
||||
|
||||
|
||||
def test():
|
||||
crew, inputs = _load()
|
||||
return crew.test(
|
||||
n_iterations=int(sys.argv[1]),
|
||||
eval_llm=sys.argv[2],
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
|
||||
def run_with_trigger():
|
||||
if len(sys.argv) < 2:
|
||||
raise ValueError("No trigger payload provided.")
|
||||
|
||||
crew, inputs = _load()
|
||||
trigger_payload = json.loads(sys.argv[1])
|
||||
return crew.kickoff(
|
||||
inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_project_scripts(root: Path, package_name: str) -> None:
|
||||
"""Ensure generated wrappers have project script entrypoints."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return
|
||||
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
entries = _project_script_entries(package_name)
|
||||
pyproject_path.write_text(
|
||||
_update_project_scripts(content, entries),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _project_script_entries(package_name: str) -> dict[str, str]:
|
||||
"""Return script entrypoints required by the generated JSON wrapper."""
|
||||
return {
|
||||
package_name: f"{package_name}.main:run",
|
||||
"run_crew": f"{package_name}.main:run",
|
||||
"train": f"{package_name}.main:train",
|
||||
"replay": f"{package_name}.main:replay",
|
||||
"test": f"{package_name}.main:test",
|
||||
"run_with_trigger": f"{package_name}.main:run_with_trigger",
|
||||
}
|
||||
|
||||
|
||||
def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
|
||||
"""Add or replace generated script entries in pyproject.toml content."""
|
||||
lines = content.rstrip().splitlines()
|
||||
header_index = _project_scripts_header_index(lines)
|
||||
if header_index is None:
|
||||
return content.rstrip() + _project_scripts_block(entries)
|
||||
|
||||
end_index = _section_end_index(lines, header_index + 1)
|
||||
seen: set[str] = set()
|
||||
for index in range(header_index + 1, end_index):
|
||||
key = _script_key(lines[index])
|
||||
if key in entries:
|
||||
lines[index] = _script_line(key, entries[key])
|
||||
seen.add(key)
|
||||
|
||||
missing_lines = [
|
||||
_script_line(key, value) for key, value in entries.items() if key not in seen
|
||||
]
|
||||
lines[end_index:end_index] = missing_lines
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _project_scripts_header_index(lines: list[str]) -> int | None:
|
||||
"""Return the line index of the project scripts table, if present."""
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() == "[project.scripts]":
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _section_end_index(lines: list[str], start_index: int) -> int:
|
||||
"""Return the exclusive end index for a TOML table section."""
|
||||
for index in range(start_index, len(lines)):
|
||||
if _SECTION_PATTERN.match(lines[index]):
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
|
||||
def _script_key(line: str) -> str | None:
|
||||
"""Return the script key for a pyproject script line."""
|
||||
match = _SCRIPT_KEY_PATTERN.match(line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
key = match.group("key")
|
||||
if key.startswith(("'", '"')) and key.endswith(("'", '"')):
|
||||
return key[1:-1]
|
||||
return key
|
||||
|
||||
|
||||
def _script_line(key: str, value: str) -> str:
|
||||
"""Render a project script TOML entry."""
|
||||
return f'{key} = "{value}"'
|
||||
|
||||
|
||||
def _project_scripts_block(entries: dict[str, str]) -> str:
|
||||
"""Render a project scripts TOML table."""
|
||||
lines = ["", "", "[project.scripts]"]
|
||||
lines.extend(_script_line(key, value) for key, value in entries.items())
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@@ -212,8 +212,16 @@ class DeployValidator:
|
||||
if crew_path is None:
|
||||
return self.results
|
||||
|
||||
agents_dir = self.project_root / "agents"
|
||||
|
||||
self._check_pyproject()
|
||||
self._check_lockfile()
|
||||
agents_dir_ok = self._check_json_agents_dir(agents_dir)
|
||||
|
||||
project = None
|
||||
try:
|
||||
project = validate_crew_project(crew_path, self.project_root / "agents")
|
||||
if agents_dir_ok:
|
||||
project = validate_crew_project(crew_path, agents_dir)
|
||||
except JSONProjectValidationError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
@@ -232,15 +240,27 @@ class DeployValidator:
|
||||
)
|
||||
return self.results
|
||||
|
||||
agents_dir = self.project_root / "agents"
|
||||
|
||||
self._check_pyproject()
|
||||
self._check_lockfile()
|
||||
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
||||
if project is not None:
|
||||
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
||||
self._check_version_vs_lockfile()
|
||||
|
||||
return self.results
|
||||
|
||||
def _check_json_agents_dir(self, agents_dir: Path) -> bool:
|
||||
if agents_dir.is_dir():
|
||||
return True
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"missing_agents_dir",
|
||||
"Cannot find agents/ directory",
|
||||
detail=(
|
||||
"JSON crew projects load agent definitions from "
|
||||
f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json."
|
||||
),
|
||||
hint="Create agents/ and add one JSON or JSONC file per agent.",
|
||||
)
|
||||
return False
|
||||
|
||||
def _check_env_vars_json(
|
||||
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
|
||||
) -> None:
|
||||
|
||||
@@ -5,19 +5,29 @@ import click
|
||||
|
||||
def kickoff_flow() -> None:
|
||||
"""
|
||||
Kickoff the flow by running a command in the UV environment.
|
||||
Kickoff the flow from declarative config or the Python UV entrypoint.
|
||||
"""
|
||||
command = ["uv", "run", "kickoff"]
|
||||
from crewai_cli.run_declarative_flow import (
|
||||
configured_project_declarative_flow,
|
||||
run_declarative_flow_in_project_env,
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
|
||||
if definition := configured_project_declarative_flow():
|
||||
run_declarative_flow_in_project_env(definition=definition)
|
||||
else:
|
||||
command = ["uv", "run", "kickoff"]
|
||||
|
||||
if result.stderr:
|
||||
click.echo(result.stderr, err=True)
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
command, capture_output=False, text=True, check=True
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while running the flow: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
if result.stderr:
|
||||
click.echo(result.stderr, err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while running the flow: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
|
||||
@@ -5,19 +5,29 @@ import click
|
||||
|
||||
def plot_flow() -> None:
|
||||
"""
|
||||
Plot the flow by running a command in the UV environment.
|
||||
Plot the flow from declarative config or the Python UV entrypoint.
|
||||
"""
|
||||
command = ["uv", "run", "plot"]
|
||||
from crewai_cli.run_declarative_flow import (
|
||||
configured_project_declarative_flow,
|
||||
plot_declarative_flow_in_project_env,
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
|
||||
if definition := configured_project_declarative_flow():
|
||||
plot_declarative_flow_in_project_env(definition)
|
||||
else:
|
||||
command = ["uv", "run", "plot"]
|
||||
|
||||
if result.stderr:
|
||||
click.echo(result.stderr, err=True)
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
command, capture_output=False, text=True, check=True
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
if result.stderr:
|
||||
click.echo(result.stderr, err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
|
||||
@@ -565,11 +565,24 @@ def run_crew(trained_agents_file: str | None = None) -> None:
|
||||
)
|
||||
|
||||
is_flow = pyproject_data.get("tool", {}).get("crewai", {}).get("type") == "flow"
|
||||
crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD
|
||||
if is_flow:
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
|
||||
declarative_flow = configured_project_declarative_flow(pyproject_data)
|
||||
else:
|
||||
declarative_flow = None
|
||||
|
||||
execute_command(crew_type, trained_agents_file=trained_agents_file)
|
||||
if declarative_flow is not None:
|
||||
click.echo("Running the Flow")
|
||||
from crewai_cli.run_declarative_flow import run_declarative_flow_in_project_env
|
||||
|
||||
run_declarative_flow_in_project_env(definition=declarative_flow)
|
||||
else:
|
||||
crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD
|
||||
|
||||
click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
|
||||
|
||||
execute_command(crew_type, trained_agents_file=trained_agents_file)
|
||||
|
||||
|
||||
def execute_command(
|
||||
|
||||
210
lib/cli/src/crewai_cli/run_declarative_flow.py
Normal file
210
lib/cli/src/crewai_cli/run_declarative_flow.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
|
||||
|
||||
def run_declarative_flow_in_project_env(
|
||||
definition: str, inputs: str | None = None
|
||||
) -> None:
|
||||
"""Run a declarative flow inside the project's Python environment."""
|
||||
if is_declarative_flow_project_env() or not _has_project_file():
|
||||
run_declarative_flow(definition=definition, inputs=inputs)
|
||||
return
|
||||
|
||||
command = ["uv", "run", "crewai", "run", "--definition", definition]
|
||||
if inputs is not None:
|
||||
command.extend(["--inputs", inputs])
|
||||
_execute_declarative_flow_command(command)
|
||||
|
||||
|
||||
def plot_declarative_flow_in_project_env(definition: str) -> None:
|
||||
"""Plot a declarative flow inside the project's Python environment."""
|
||||
if is_declarative_flow_project_env() or not _has_project_file():
|
||||
plot_declarative_flow(definition=definition)
|
||||
return
|
||||
|
||||
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
|
||||
|
||||
|
||||
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
|
||||
"""Run a declarative flow from a YAML/JSON file path."""
|
||||
parsed_inputs = _parse_inputs(inputs)
|
||||
|
||||
try:
|
||||
flow = load_declarative_flow(definition)
|
||||
result = flow.kickoff(inputs=parsed_inputs)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"An error occurred while running the declarative flow: {exc}", err=True
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
click.echo(_format_result(result))
|
||||
|
||||
|
||||
def plot_declarative_flow(definition: str) -> None:
|
||||
"""Plot a declarative flow from a YAML/JSON file path."""
|
||||
try:
|
||||
flow = load_declarative_flow(definition)
|
||||
flow.plot()
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"An error occurred while plotting the declarative flow: {exc}", err=True
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def load_declarative_flow(definition: str) -> Any:
|
||||
"""Load a declarative Flow instance from a YAML/JSON file path."""
|
||||
try:
|
||||
from crewai.flow.flow import Flow
|
||||
from crewai.flow.flow_definition import FlowDefinition
|
||||
except ImportError as exc:
|
||||
click.echo(
|
||||
"Running declarative flows requires the full crewai package.",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
definition_path = Path(definition).expanduser()
|
||||
definition_source = _read_declarative_flow_source(definition_path, definition)
|
||||
|
||||
flow_definition = _parse_declarative_flow(
|
||||
FlowDefinition,
|
||||
definition_source,
|
||||
source_path=definition_path,
|
||||
)
|
||||
return Flow.from_definition(flow_definition)
|
||||
|
||||
|
||||
def configured_project_declarative_flow(
|
||||
pyproject_data: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Return the configured declarative flow source for flow projects."""
|
||||
if pyproject_data is None:
|
||||
try:
|
||||
from crewai_cli.utils import read_toml
|
||||
|
||||
pyproject_data = read_toml()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
crewai_config = pyproject_data.get("tool", {}).get("crewai", {})
|
||||
if crewai_config.get("type") != "flow":
|
||||
return None
|
||||
definition = crewai_config.get("definition")
|
||||
return definition if isinstance(definition, str) and definition.strip() else None
|
||||
|
||||
|
||||
def _execute_declarative_flow_command(command: list[str]) -> None:
|
||||
env = build_env_with_all_tool_credentials()
|
||||
|
||||
try:
|
||||
subprocess.run( # noqa: S603
|
||||
command,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(e.returncode) from e
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
f"An unexpected error occurred while running the declarative flow: {e}",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
def is_declarative_flow_project_env() -> bool:
|
||||
import os
|
||||
|
||||
return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None
|
||||
|
||||
|
||||
def _has_project_file(project_root: Path | None = None) -> bool:
|
||||
root = project_root or Path.cwd()
|
||||
return (root / "pyproject.toml").is_file()
|
||||
|
||||
|
||||
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
|
||||
if inputs is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(inputs)
|
||||
except json.JSONDecodeError as exc:
|
||||
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
click.echo("Invalid --inputs JSON: expected an object.", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def _read_declarative_flow_source(path: Path, definition: str) -> str:
|
||||
try:
|
||||
if path.is_file():
|
||||
source = _read_declarative_flow_file(path)
|
||||
elif path.exists():
|
||||
click.echo(
|
||||
f"Invalid --definition path: {definition} is not a file.", err=True
|
||||
)
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
click.echo(
|
||||
f"Invalid --definition path: {definition} does not exist.", err=True
|
||||
)
|
||||
raise SystemExit(1)
|
||||
except OSError as exc:
|
||||
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
return source
|
||||
|
||||
|
||||
def _read_declarative_flow_file(path: Path) -> str:
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
click.echo(
|
||||
f"Unable to read --definition path {path}: {exc}",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
return source
|
||||
|
||||
|
||||
def _parse_declarative_flow(
|
||||
flow_definition_cls: type[Any], source: str, *, source_path: Path
|
||||
) -> Any:
|
||||
if _looks_like_json(source):
|
||||
return flow_definition_cls.from_json(source, source_path=source_path)
|
||||
|
||||
return flow_definition_cls.from_yaml(source, source_path=source_path)
|
||||
|
||||
|
||||
def _looks_like_json(source: str) -> bool:
|
||||
stripped = source.lstrip()
|
||||
return stripped.startswith("{")
|
||||
|
||||
|
||||
def _format_result(result: Any) -> str:
|
||||
raw_result = getattr(result, "raw", result)
|
||||
if isinstance(raw_result, str):
|
||||
return raw_result
|
||||
|
||||
try:
|
||||
return json.dumps(raw_result, default=str)
|
||||
except TypeError:
|
||||
return str(raw_result)
|
||||
@@ -1,113 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
|
||||
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
|
||||
try:
|
||||
from crewai.flow.flow import Flow
|
||||
from crewai.flow.flow_definition import FlowDefinition
|
||||
except ImportError as exc:
|
||||
click.echo(
|
||||
"Running flows from definitions requires the full crewai package.",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
parsed_inputs = _parse_inputs(inputs)
|
||||
definition_source = _read_definition_source(definition)
|
||||
|
||||
try:
|
||||
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
|
||||
flow = Flow.from_definition(flow_definition)
|
||||
result = flow.kickoff(inputs=parsed_inputs)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"An error occurred while running the flow definition: {exc}", err=True
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
click.echo(_format_result(result))
|
||||
|
||||
|
||||
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
|
||||
if inputs is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(inputs)
|
||||
except json.JSONDecodeError as exc:
|
||||
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
click.echo("Invalid --inputs JSON: expected an object.", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def _read_definition_source(definition: str) -> str:
|
||||
path = Path(definition).expanduser()
|
||||
try:
|
||||
is_file = path.is_file()
|
||||
except OSError as exc:
|
||||
if _looks_like_inline_definition(definition):
|
||||
return definition
|
||||
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if is_file:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
click.echo(
|
||||
f"Unable to read --definition path {path}: {exc}",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
try:
|
||||
if path.exists():
|
||||
click.echo(
|
||||
f"Invalid --definition path: {definition} is not a file.", err=True
|
||||
)
|
||||
raise SystemExit(1)
|
||||
except OSError as exc:
|
||||
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
return definition
|
||||
|
||||
|
||||
def _looks_like_inline_definition(definition: str) -> bool:
|
||||
stripped = definition.lstrip()
|
||||
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
|
||||
|
||||
|
||||
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
|
||||
if _looks_like_json(source):
|
||||
return flow_definition_cls.from_json(source)
|
||||
|
||||
return flow_definition_cls.from_yaml(source)
|
||||
|
||||
|
||||
def _looks_like_json(source: str) -> bool:
|
||||
stripped = source.lstrip()
|
||||
return stripped.startswith("{")
|
||||
|
||||
|
||||
def _format_result(result: Any) -> str:
|
||||
raw_result = getattr(result, "raw", result)
|
||||
if isinstance(raw_result, str):
|
||||
return raw_result
|
||||
|
||||
try:
|
||||
return json.dumps(raw_result, default=str)
|
||||
except TypeError:
|
||||
return str(raw_result)
|
||||
5
lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore
vendored
Normal file
5
lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.crewai/
|
||||
output/
|
||||
17
lib/cli/src/crewai_cli/templates/declarative_flow/README.md
Normal file
17
lib/cli/src/crewai_cli/templates/declarative_flow/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# {{name}} Flow
|
||||
|
||||
This project defines a CrewAI Flow in `flow.yaml`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
crewai install
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
crewai run
|
||||
```
|
||||
|
||||
Edit `flow.yaml` to change the flow. Add reusable crews under `crews/`, custom Python tools under `tools/`, and shared knowledge files under `knowledge/`.
|
||||
15
lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml
Normal file
15
lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
schema: crewai.flow/v1
|
||||
name: {{flow_name}}
|
||||
description: A declarative CrewAI Flow.
|
||||
|
||||
state:
|
||||
type: dict
|
||||
default:
|
||||
topic: AI agents
|
||||
|
||||
methods:
|
||||
start:
|
||||
start: true
|
||||
do:
|
||||
call: expression
|
||||
expr: state.topic
|
||||
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "{{folder_name}}"
|
||||
version = "0.1.0"
|
||||
description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a2"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.11.23,<0.12"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-root = ""
|
||||
module-name = ["crews", "tools", "knowledge", "skills"]
|
||||
source-include = ["flow.yaml"]
|
||||
|
||||
[tool.crewai]
|
||||
type = "flow"
|
||||
definition = "flow.yaml"
|
||||
@@ -132,7 +132,7 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
|
||||
assert names == {"pyproject.toml"}
|
||||
|
||||
|
||||
def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
|
||||
def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
@@ -157,8 +157,6 @@ type = "crew"
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
crew_py = archive.read("src/json_crew/crew.py").decode()
|
||||
main_py = archive.read("src/json_crew/main.py").decode()
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
@@ -166,18 +164,50 @@ type = "crew"
|
||||
assert "uv.lock" not in names
|
||||
assert "crew.jsonc" in names
|
||||
assert "agents/researcher.jsonc" in names
|
||||
assert "src/json_crew/__init__.py" in names
|
||||
assert "src/json_crew/crew.py" in names
|
||||
assert "src/json_crew/main.py" in names
|
||||
assert "src/json_crew/config/agents.yaml" in names
|
||||
assert "src/json_crew/config/tasks.yaml" in names
|
||||
assert "load_crew(_crew_path())" in crew_py
|
||||
assert "JsonCrew" in crew_py
|
||||
assert "from json_crew.crew import JsonCrew" in main_py
|
||||
assert "run_crew = \"json_crew.main:run\"" in pyproject
|
||||
assert all(not name.startswith("src/") for name in names)
|
||||
assert "run_crew" not in pyproject
|
||||
assert "json_crew =" not in pyproject
|
||||
assert "[project.scripts]" not in pyproject
|
||||
|
||||
|
||||
def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
|
||||
def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "json_crew"
|
||||
version = "0.1.0"
|
||||
dependencies = ["crewai[tools]==1.14.8a1"]
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
+ "\n"
|
||||
)
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
(tmp_path / "agents").mkdir()
|
||||
(tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert names == {
|
||||
"agents/foo.jsonc",
|
||||
"crew.jsonc",
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
}
|
||||
assert "run_crew" not in pyproject
|
||||
assert "json_crew =" not in pyproject
|
||||
assert "[project.scripts]" not in pyproject
|
||||
|
||||
|
||||
def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
@@ -203,14 +233,10 @@ type = "crew"
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert 'json_crew = "json_crew.main:run"' in pyproject
|
||||
assert 'run_crew = "json_crew.main:run"' in pyproject
|
||||
assert 'train = "json_crew.main:train"' in pyproject
|
||||
assert 'replay = "json_crew.main:replay"' in pyproject
|
||||
assert 'test = "json_crew.main:test"' in pyproject
|
||||
assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
|
||||
assert 'json_crew = "old.module:run"' in pyproject
|
||||
assert 'run_crew = "old.module:run"' in pyproject
|
||||
assert 'custom = "custom.module:main"' in pyproject
|
||||
assert "old.module:run" not in pyproject
|
||||
assert pyproject.count("[project.scripts]") == 1
|
||||
assert "[tool.crewai]" in pyproject
|
||||
|
||||
|
||||
@@ -221,7 +247,7 @@ type = "crew"
|
||||
'[tool]\ncrewai = "invalid"\n',
|
||||
],
|
||||
)
|
||||
def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
|
||||
def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
|
||||
tmp_path: Path, tool_config: str
|
||||
):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
@@ -244,12 +270,13 @@ version = "0.1.0"
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert "src/json_crew/crew.py" in names
|
||||
assert "src/json_crew/main.py" in names
|
||||
assert "run_crew = \"json_crew.main:run\"" in pyproject
|
||||
assert names == {"crew.jsonc", "pyproject.toml"}
|
||||
assert "run_crew" not in pyproject
|
||||
assert "json_crew =" not in pyproject
|
||||
assert "[project.scripts]" not in pyproject
|
||||
|
||||
|
||||
def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
|
||||
def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
@@ -263,8 +290,15 @@ type = "crew"
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Could not derive a valid Python package name",
|
||||
):
|
||||
create_project_zip("invalid", project_dir=tmp_path)
|
||||
archive_path = create_project_zip("invalid", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert names == {"crew.jsonc", "pyproject.toml"}
|
||||
assert "run_crew" not in pyproject
|
||||
assert "json_crew =" not in pyproject
|
||||
assert "[project.scripts]" not in pyproject
|
||||
|
||||
@@ -200,6 +200,41 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
|
||||
assert "runtime-only" in finding.detail
|
||||
|
||||
|
||||
def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
for path in (tmp_path / "agents").iterdir():
|
||||
path.unlink()
|
||||
(tmp_path / "agents").rmdir()
|
||||
|
||||
v = DeployValidator(project_root=tmp_path)
|
||||
v.run()
|
||||
|
||||
codes = _codes(v)
|
||||
assert "missing_agents_dir" in codes
|
||||
assert "missing_src_dir" not in codes
|
||||
assert "missing_crew_py" not in codes
|
||||
assert "missing_agents_yaml" not in codes
|
||||
assert "missing_tasks_yaml" not in codes
|
||||
|
||||
|
||||
def test_json_crew_reports_project_metadata_before_invalid_json(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
(tmp_path / "pyproject.toml").unlink()
|
||||
(tmp_path / "uv.lock").unlink()
|
||||
(tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n')
|
||||
|
||||
v = DeployValidator(project_root=tmp_path)
|
||||
v.run()
|
||||
|
||||
codes = _codes(v)
|
||||
assert "missing_pyproject" in codes
|
||||
assert "missing_lockfile" in codes
|
||||
assert "invalid_crew_json" in codes
|
||||
assert "missing_src_dir" not in codes
|
||||
|
||||
|
||||
def test_missing_pyproject_errors(tmp_path: Path) -> None:
|
||||
v = _run_without_import_check(tmp_path)
|
||||
assert "missing_pyproject" in _codes(v)
|
||||
|
||||
@@ -130,31 +130,49 @@ def test_run_uses_project_runner_by_default(run_crew, runner):
|
||||
assert "experimental" not in result.output.lower()
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_flow_definition")
|
||||
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
|
||||
@mock.patch("crewai_cli.cli.run_declarative_flow_in_project_env")
|
||||
def test_run_with_definition_uses_project_env_declarative_runner(
|
||||
run_declarative_flow_in_project_env, runner, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
|
||||
result = runner.invoke(
|
||||
run,
|
||||
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Warning: `crewai run --definition` is experimental and may change without notice."
|
||||
in result.output
|
||||
assert "experimental" not in result.output.lower()
|
||||
run_declarative_flow_in_project_env.assert_called_once_with(
|
||||
definition="flow.yaml", inputs='{"topic":"AI"}'
|
||||
)
|
||||
run_flow_definition.assert_called_once_with(
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_declarative_flow_in_project_env")
|
||||
def test_run_with_definition_delegates_project_env_detection_to_runner(
|
||||
run_declarative_flow_in_project_env, runner
|
||||
):
|
||||
result = runner.invoke(
|
||||
run,
|
||||
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
|
||||
env={"UV_RUN_RECURSION_DEPTH": "1"},
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
run_declarative_flow_in_project_env.assert_called_once_with(
|
||||
definition="flow.yaml", inputs='{"topic":"AI"}'
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_crew")
|
||||
@mock.patch("crewai_cli.cli.run_flow_definition")
|
||||
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
|
||||
@mock.patch("crewai_cli.cli.run_declarative_flow_in_project_env")
|
||||
def test_run_rejects_inputs_without_definition(
|
||||
run_declarative_flow_in_project_env, run_crew, runner
|
||||
):
|
||||
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "Error: --inputs requires --definition" in result.output
|
||||
run_flow_definition.assert_not_called()
|
||||
run_declarative_flow_in_project_env.assert_not_called()
|
||||
run_crew.assert_not_called()
|
||||
|
||||
|
||||
@@ -166,6 +184,23 @@ def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner
|
||||
create_json_crew.assert_called_once_with("DMN Crew", None, True)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.create_flow.create_flow")
|
||||
def test_create_flow_declarative_uses_declarative_scaffold(create_flow, runner):
|
||||
result = runner.invoke(create, ["flow", "My Flow", "--declarative"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
create_flow.assert_called_once_with("My Flow", declarative=True)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.create_json_crew.create_json_crew")
|
||||
def test_create_crew_rejects_declarative_flag(create_json_crew, runner):
|
||||
result = runner.invoke(create, ["crew", "My Crew", "--declarative"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "--declarative can only be used with flow projects" in result.output
|
||||
create_json_crew.assert_not_called()
|
||||
|
||||
|
||||
def test_create_requires_type_in_dmn_mode(runner):
|
||||
result = runner.invoke(create, env={"CREWAI_DMN": "True"})
|
||||
|
||||
|
||||
@@ -712,8 +712,26 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
|
||||
default_llm="openai/gpt-5.5",
|
||||
)
|
||||
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
|
||||
assert not (tmp_path / "json_crew" / "src").exists()
|
||||
assert not (tmp_path / "json_crew" / "tests").exists()
|
||||
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
|
||||
generated_paths = {
|
||||
path.relative_to(tmp_path / "json_crew").as_posix()
|
||||
for path in (tmp_path / "json_crew").rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
assert not any(
|
||||
path.endswith("/crew.py") or path == "crew.py" for path in generated_paths
|
||||
)
|
||||
assert not any(
|
||||
path.endswith("/agents.yaml") or path == "agents.yaml"
|
||||
for path in generated_paths
|
||||
)
|
||||
assert not any(
|
||||
path.endswith("/tasks.yaml") or path == "tasks.yaml"
|
||||
for path in generated_paths
|
||||
)
|
||||
assert not any(path.startswith("src/") for path in generated_paths)
|
||||
|
||||
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
|
||||
dependency = pyproject["project"]["dependencies"][0]
|
||||
@@ -849,7 +867,7 @@ def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatc
|
||||
crew_template = (project_root / "crew.jsonc").read_text()
|
||||
agent_template = (project_root / "agents" / "researcher.jsonc").read_text()
|
||||
|
||||
assert '"memory": false' in crew_template
|
||||
assert '"memory": true' in crew_template
|
||||
assert '"description": "Research current AI trends and write a concise summary."' in (
|
||||
crew_template
|
||||
)
|
||||
|
||||
31
lib/cli/tests/test_create_flow.py
Normal file
31
lib/cli/tests/test_create_flow.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from crewai_cli.create_flow import create_flow
|
||||
|
||||
|
||||
def test_create_flow_declarative_scaffold(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
create_flow("Research Flow", declarative=True)
|
||||
|
||||
project_root = tmp_path / "research_flow"
|
||||
assert (project_root / "flow.yaml").is_file()
|
||||
assert (project_root / "crews").is_dir()
|
||||
assert (project_root / "tools").is_dir()
|
||||
assert (project_root / "knowledge").is_dir()
|
||||
assert (project_root / "skills").is_dir()
|
||||
assert not (project_root / "src").exists()
|
||||
|
||||
pyproject = (project_root / "pyproject.toml").read_text(encoding="utf-8")
|
||||
assert 'type = "flow"' in pyproject
|
||||
assert 'definition = "flow.yaml"' in pyproject
|
||||
assert (
|
||||
'only-include = ["flow.yaml", "crews", "tools", "knowledge", "skills"]'
|
||||
in pyproject
|
||||
)
|
||||
|
||||
flow_definition = (project_root / "flow.yaml").read_text(encoding="utf-8")
|
||||
assert "schema: crewai.flow/v1" in flow_definition
|
||||
assert "name: ResearchFlow" in flow_definition
|
||||
139
lib/cli/tests/test_flow_commands.py
Normal file
139
lib/cli/tests/test_flow_commands.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import crewai_cli.kickoff_flow as kickoff_flow_module
|
||||
import crewai_cli.plot_flow as plot_flow_module
|
||||
|
||||
|
||||
def test_kickoff_flow_uses_configured_definition(monkeypatch):
|
||||
calls = []
|
||||
subprocess_calls = []
|
||||
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.configured_project_declarative_flow",
|
||||
lambda: "flow.yaml",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.run_declarative_flow",
|
||||
lambda **kwargs: calls.append({"in_process": kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.run_declarative_flow_in_project_env",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
kickoff_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
kickoff_flow_module.kickoff_flow()
|
||||
|
||||
assert calls == [{"definition": "flow.yaml"}]
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
def test_kickoff_flow_keeps_python_entrypoint_without_definition(monkeypatch):
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.configured_project_declarative_flow",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
kickoff_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
kickoff_flow_module.kickoff_flow()
|
||||
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
(["uv", "run", "kickoff"],),
|
||||
{"capture_output": False, "text": True, "check": True},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_plot_flow_uses_configured_definition(monkeypatch):
|
||||
calls = []
|
||||
subprocess_calls = []
|
||||
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.configured_project_declarative_flow",
|
||||
lambda: "flow.yaml",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.plot_declarative_flow",
|
||||
lambda definition: calls.append({"in_process": definition}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.plot_declarative_flow_in_project_env",
|
||||
lambda definition: calls.append(definition),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plot_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
plot_flow_module.plot_flow()
|
||||
|
||||
assert calls == ["flow.yaml"]
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
def test_plot_flow_delegates_project_env_detection_to_runner(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.configured_project_declarative_flow",
|
||||
lambda: "flow.yaml",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.plot_declarative_flow_in_project_env",
|
||||
lambda definition: calls.append({"project_env": definition}),
|
||||
)
|
||||
|
||||
plot_flow_module.plot_flow()
|
||||
|
||||
assert calls == [{"project_env": "flow.yaml"}]
|
||||
|
||||
|
||||
def test_plot_flow_keeps_python_entrypoint_without_definition(monkeypatch):
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.run_declarative_flow.configured_project_declarative_flow",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plot_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
plot_flow_module.plot_flow()
|
||||
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
(["uv", "run", "plot"],),
|
||||
{"capture_output": False, "text": True, "check": True},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
assert configured_project_declarative_flow() == "flow.yaml"
|
||||
@@ -51,6 +51,63 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
|
||||
assert called == {"trained_agents_file": "some.pkl"}
|
||||
|
||||
|
||||
def test_run_crew_uses_configured_declarative_flow(monkeypatch):
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
|
||||
monkeypatch.setattr(run_crew_module, "get_crewai_version", lambda: "999.0.0")
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"read_toml",
|
||||
lambda: {"tool": {"crewai": {"type": "flow", "definition": "flow.yaml"}}},
|
||||
)
|
||||
execute_calls = []
|
||||
declarative_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"execute_command",
|
||||
lambda *args, **kwargs: execute_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
import crewai_cli.run_declarative_flow as run_declarative_flow_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"run_declarative_flow_in_project_env",
|
||||
lambda **kwargs: declarative_calls.append(kwargs),
|
||||
)
|
||||
|
||||
run_crew_module.run_crew()
|
||||
|
||||
assert declarative_calls == [{"definition": "flow.yaml"}]
|
||||
assert execute_calls == []
|
||||
|
||||
|
||||
def test_run_crew_without_definition_keeps_python_flow_runner(monkeypatch):
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
|
||||
monkeypatch.setattr(run_crew_module, "get_crewai_version", lambda: "999.0.0")
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"read_toml",
|
||||
lambda: {"tool": {"crewai": {"type": "flow"}}},
|
||||
)
|
||||
execute_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"execute_command",
|
||||
lambda *args, **kwargs: execute_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
run_crew_module.run_crew(trained_agents_file="trained.pkl")
|
||||
|
||||
assert execute_calls == [
|
||||
(
|
||||
(run_crew_module.CrewType.FLOW,),
|
||||
{"trained_agents_file": "trained.pkl"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path):
|
||||
"""JSON crew runs should execute inside the project uv environment."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
301
lib/cli/tests/test_run_declarative_flow.py
Normal file
301
lib/cli/tests/test_run_declarative_flow.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import crewai_cli.run_declarative_flow as run_declarative_flow_module
|
||||
from crewai_cli.run_declarative_flow import run_declarative_flow
|
||||
|
||||
|
||||
class _FakeFlow:
|
||||
def __init__(self, definition):
|
||||
self.definition = definition
|
||||
|
||||
def kickoff(self, inputs=None):
|
||||
return {
|
||||
"flow": self.definition["name"],
|
||||
"inputs": inputs or {},
|
||||
}
|
||||
|
||||
|
||||
class _FakeFlowFactory:
|
||||
@classmethod
|
||||
def from_definition(cls, definition):
|
||||
return _FakeFlow(definition)
|
||||
|
||||
|
||||
class _FakeFlowDefinition:
|
||||
@classmethod
|
||||
def from_yaml(cls, source, *, source_path):
|
||||
return yaml.safe_load(source)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, source, *, source_path):
|
||||
return json.loads(source)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_flow_runtime(monkeypatch):
|
||||
crewai_module = types.ModuleType("crewai")
|
||||
flow_package = types.ModuleType("crewai.flow")
|
||||
flow_module = types.ModuleType("crewai.flow.flow")
|
||||
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
|
||||
|
||||
flow_module.Flow = _FakeFlowFactory
|
||||
flow_definition_module.FlowDefinition = _FakeFlowDefinition
|
||||
|
||||
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "crewai.flow.flow_definition", flow_definition_module
|
||||
)
|
||||
|
||||
|
||||
def _captured_json(capsys):
|
||||
return json.loads(capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_run_declarative_flow_reads_definition_file(
|
||||
tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
run_declarative_flow(str(definition_path), '{"topic":"AI"}')
|
||||
|
||||
assert _captured_json(capsys) == {
|
||||
"flow": "TestFlow",
|
||||
"inputs": {"topic": "AI"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "definition_source", "expected_flow_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"flow.yaml",
|
||||
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
|
||||
"YamlFileFlow",
|
||||
id="yaml-file",
|
||||
),
|
||||
pytest.param(
|
||||
"flow.json",
|
||||
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
|
||||
"JsonFlow",
|
||||
id="json-file",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_run_declarative_flow_accepts_definition_files(
|
||||
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / filename
|
||||
definition_path.write_text(definition_source)
|
||||
|
||||
run_declarative_flow(str(definition_path))
|
||||
|
||||
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
|
||||
|
||||
|
||||
def test_run_declarative_flow_rejects_non_object_inputs(
|
||||
tmp_path, fake_flow_runtime, capsys
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow(str(definition_path), '["not", "an", "object"]')
|
||||
|
||||
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_declarative_flow_reports_unreadable_file(
|
||||
monkeypatch, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
def raise_permission_error(self, *args, **kwargs):
|
||||
raise PermissionError("no access")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow(str(definition_path))
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "Unable to read --definition path" in err
|
||||
assert str(definition_path) in err
|
||||
assert "no access" in err
|
||||
|
||||
|
||||
def test_run_declarative_flow_reports_missing_file(capsys, fake_flow_runtime):
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow("missing-flow.yaml")
|
||||
|
||||
assert (
|
||||
"Invalid --definition path: missing-flow.yaml does not exist."
|
||||
in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_run_declarative_flow_in_project_env_uses_uv(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"build_env_with_all_tool_credentials",
|
||||
lambda: {"EXISTING": "value"},
|
||||
)
|
||||
|
||||
def fake_subprocess_run(command, **kwargs):
|
||||
subprocess_calls.append((command, kwargs))
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module.subprocess, "run", fake_subprocess_run
|
||||
)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow_in_project_env(
|
||||
"flow.yaml", '{"topic":"AI"}'
|
||||
)
|
||||
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"crewai",
|
||||
"run",
|
||||
"--definition",
|
||||
"flow.yaml",
|
||||
"--inputs",
|
||||
'{"topic":"AI"}',
|
||||
],
|
||||
{
|
||||
"capture_output": False,
|
||||
"text": True,
|
||||
"check": True,
|
||||
"env": {"EXISTING": "value"},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_run_declarative_flow_in_project_env_falls_back_without_pyproject(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"run_declarative_flow",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml")
|
||||
|
||||
assert calls == [{"definition": "flow.yaml", "inputs": None}]
|
||||
|
||||
|
||||
def test_run_declarative_flow_in_project_env_uses_in_process_runner_inside_uv(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
calls = []
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"run_declarative_flow",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow_in_project_env(
|
||||
"flow.yaml", '{"topic":"AI"}'
|
||||
)
|
||||
|
||||
assert calls == [{"definition": "flow.yaml", "inputs": '{"topic":"AI"}'}]
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
def test_plot_declarative_flow_in_project_env_uses_uv(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"build_env_with_all_tool_credentials",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
def fake_subprocess_run(command, **kwargs):
|
||||
subprocess_calls.append((command, kwargs))
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module.subprocess, "run", fake_subprocess_run
|
||||
)
|
||||
|
||||
run_declarative_flow_module.plot_declarative_flow_in_project_env("flow.yaml")
|
||||
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"crewai",
|
||||
"flow",
|
||||
"plot",
|
||||
],
|
||||
{
|
||||
"capture_output": False,
|
||||
"text": True,
|
||||
"check": True,
|
||||
"env": {},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_plot_declarative_flow_in_project_env_uses_in_process_runner_inside_uv(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
calls = []
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module,
|
||||
"plot_declarative_flow",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
run_declarative_flow_module.plot_declarative_flow_in_project_env("flow.yaml")
|
||||
|
||||
assert calls == [{"definition": "flow.yaml"}]
|
||||
assert subprocess_calls == []
|
||||
@@ -1,156 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from crewai_cli.run_flow_definition import run_flow_definition
|
||||
|
||||
|
||||
class _FakeFlow:
|
||||
def __init__(self, definition):
|
||||
self.definition = definition
|
||||
|
||||
def kickoff(self, inputs=None):
|
||||
return {
|
||||
"flow": self.definition["name"],
|
||||
"inputs": inputs or {},
|
||||
}
|
||||
|
||||
|
||||
class _FakeFlowFactory:
|
||||
@classmethod
|
||||
def from_definition(cls, definition):
|
||||
return _FakeFlow(definition)
|
||||
|
||||
|
||||
class _FakeFlowDefinition:
|
||||
@classmethod
|
||||
def from_yaml(cls, source):
|
||||
return yaml.safe_load(source)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, source):
|
||||
return json.loads(source)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_flow_runtime(monkeypatch):
|
||||
crewai_module = types.ModuleType("crewai")
|
||||
flow_package = types.ModuleType("crewai.flow")
|
||||
flow_module = types.ModuleType("crewai.flow.flow")
|
||||
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
|
||||
|
||||
flow_module.Flow = _FakeFlowFactory
|
||||
flow_definition_module.FlowDefinition = _FakeFlowDefinition
|
||||
|
||||
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "crewai.flow.flow_definition", flow_definition_module
|
||||
)
|
||||
|
||||
|
||||
def _captured_json(capsys):
|
||||
return json.loads(capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_run_flow_definition_reads_definition_file(
|
||||
tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
run_flow_definition(str(definition_path), '{"topic":"AI"}')
|
||||
|
||||
assert _captured_json(capsys) == {
|
||||
"flow": "TestFlow",
|
||||
"inputs": {"topic": "AI"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("definition_source", "expected_flow_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"schema: crewai.flow/v1\nname: InlineFlow\n",
|
||||
"InlineFlow",
|
||||
id="inline-yaml",
|
||||
),
|
||||
pytest.param(
|
||||
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
|
||||
"InlineJsonFlow",
|
||||
id="inline-json",
|
||||
),
|
||||
pytest.param(
|
||||
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
|
||||
"JsonFlow" * 500,
|
||||
id="large-inline-json",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_run_flow_definition_accepts_inline_definitions(
|
||||
definition_source, expected_flow_name, capsys, fake_flow_runtime
|
||||
):
|
||||
run_flow_definition(definition_source)
|
||||
|
||||
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "definition_source", "expected_flow_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"flow.yaml",
|
||||
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
|
||||
"YamlFileFlow",
|
||||
id="yaml-file",
|
||||
),
|
||||
pytest.param(
|
||||
"flow.json",
|
||||
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
|
||||
"JsonFlow",
|
||||
id="json-file",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_run_flow_definition_accepts_definition_files(
|
||||
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / filename
|
||||
definition_path.write_text(definition_source)
|
||||
|
||||
run_flow_definition(str(definition_path))
|
||||
|
||||
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
|
||||
|
||||
|
||||
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
|
||||
|
||||
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_flow_definition_reports_unreadable_file(
|
||||
monkeypatch, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
def raise_permission_error(self, *args, **kwargs):
|
||||
raise PermissionError("no access")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_flow_definition(str(definition_path))
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "Unable to read --definition path" in err
|
||||
assert str(definition_path) in err
|
||||
assert "no access" in err
|
||||
@@ -20,82 +20,6 @@ from urllib.parse import urlparse
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
|
||||
_ALLOWED_DIRS_ENV = "CREWAI_TOOLS_ALLOWED_DIRS"
|
||||
|
||||
|
||||
def _get_allowed_roots(
|
||||
base_dir: str | None = None,
|
||||
allowed_dirs: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Build the deny-by-default set of allowed root directories.
|
||||
|
||||
Roots are drawn from, in order:
|
||||
|
||||
1. ``base_dir`` (defaults to the current working directory),
|
||||
2. the ``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, split on
|
||||
``os.pathsep``,
|
||||
3. the caller-supplied ``allowed_dirs`` list.
|
||||
|
||||
Every root is resolved with :func:`os.path.realpath` so a symlinked root
|
||||
is compared by its real location. Empty entries are ignored and duplicates
|
||||
are collapsed while preserving order. The first element is always the
|
||||
primary root used to resolve relative candidate paths.
|
||||
|
||||
The filesystem root (``os.sep``, e.g. ``"/"``) is never accepted as an
|
||||
*implicitly defaulted* root. When ``base_dir`` is not supplied and the
|
||||
current working directory is ``/`` -- common in containers started without
|
||||
a ``WORKDIR`` -- defaulting to it would make every absolute path "within"
|
||||
the allow-list and disable confinement entirely. In that case the cwd
|
||||
default is dropped; an operator who genuinely wants the whole filesystem
|
||||
must opt in explicitly via ``base_dir``, ``allowed_dirs``, or
|
||||
``CREWAI_TOOLS_ALLOWED_DIRS``. If no usable root remains, a ``ValueError``
|
||||
is raised rather than silently allowing everything.
|
||||
"""
|
||||
primary_explicit = base_dir is not None
|
||||
primary = base_dir if base_dir is not None else os.getcwd()
|
||||
|
||||
# (root, is_explicit) -- explicit roots are operator-provided and may
|
||||
# legitimately include the filesystem root as an opt-in.
|
||||
raw_roots: list[tuple[str, bool]] = [(primary, primary_explicit)]
|
||||
|
||||
env_dirs = os.environ.get(_ALLOWED_DIRS_ENV, "")
|
||||
if env_dirs:
|
||||
raw_roots.extend((d, True) for d in env_dirs.split(os.pathsep) if d)
|
||||
|
||||
if allowed_dirs:
|
||||
raw_roots.extend((d, True) for d in allowed_dirs if d)
|
||||
|
||||
resolved: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for root, is_explicit in raw_roots:
|
||||
real = os.path.realpath(root)
|
||||
if real == os.sep and not is_explicit:
|
||||
# Refuse to let an unconfigured cwd of "/" open the whole filesystem.
|
||||
continue
|
||||
if real not in seen:
|
||||
seen.add(real)
|
||||
resolved.append(real)
|
||||
|
||||
if not resolved:
|
||||
raise ValueError(
|
||||
"No safe allowed directory could be determined: the current working "
|
||||
f"directory is the filesystem root ('{os.sep}'). Set "
|
||||
f"{_ALLOWED_DIRS_ENV} to an explicit directory, pass "
|
||||
f"base_dir/allowed_dirs, or set {_UNSAFE_PATHS_ENV}=true to bypass "
|
||||
"path validation."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_within_root(resolved_path: str, resolved_root: str) -> bool:
|
||||
"""Return True if *resolved_path* equals *resolved_root* or lives beneath it.
|
||||
|
||||
When ``resolved_root`` already ends with a separator (e.g. the filesystem
|
||||
root ``"/"``), appending ``os.sep`` would double it, so the root is used
|
||||
as-is for the prefix in that case.
|
||||
"""
|
||||
prefix = resolved_root if resolved_root.endswith(os.sep) else resolved_root + os.sep
|
||||
return resolved_path == resolved_root or resolved_path.startswith(prefix)
|
||||
|
||||
|
||||
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
|
||||
@@ -128,32 +52,21 @@ def _is_escape_hatch_enabled() -> bool:
|
||||
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def validate_file_path(
|
||||
path: str,
|
||||
base_dir: str | None = None,
|
||||
*,
|
||||
allowed_dirs: list[str] | None = None,
|
||||
) -> str:
|
||||
def validate_file_path(path: str, base_dir: str | None = None) -> str:
|
||||
"""Validate that a file path is safe to read.
|
||||
|
||||
Resolves symlinks and ``..`` components, then checks that the resolved
|
||||
path falls within at least one allowed root directory. The allow-list is
|
||||
built from *base_dir* (defaults to the current working directory), the
|
||||
``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, and *allowed_dirs* —
|
||||
see :func:`_get_allowed_roots`. Access is denied by default for anything
|
||||
outside that set.
|
||||
path falls within *base_dir* (defaults to the current working directory).
|
||||
|
||||
Args:
|
||||
path: The file path to validate.
|
||||
base_dir: Primary allowed root. Defaults to ``os.getcwd()`` and is
|
||||
used to resolve relative ``path`` values.
|
||||
allowed_dirs: Additional allowed root directories.
|
||||
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
|
||||
|
||||
Returns:
|
||||
The resolved, validated absolute path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path escapes every allowed directory.
|
||||
ValueError: If the path escapes the allowed directory.
|
||||
"""
|
||||
if _is_escape_hatch_enabled():
|
||||
logger.warning(
|
||||
@@ -163,30 +76,30 @@ def validate_file_path(
|
||||
)
|
||||
return os.path.realpath(path)
|
||||
|
||||
allowed_roots = _get_allowed_roots(base_dir, allowed_dirs)
|
||||
primary_root = allowed_roots[0]
|
||||
if base_dir is None:
|
||||
base_dir = os.getcwd()
|
||||
|
||||
resolved_base = os.path.realpath(base_dir)
|
||||
resolved_path = os.path.realpath(
|
||||
path if os.path.isabs(path) else os.path.join(primary_root, path)
|
||||
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
|
||||
)
|
||||
|
||||
if any(_is_within_root(resolved_path, root) for root in allowed_roots):
|
||||
return resolved_path
|
||||
# Ensure the resolved path is within the base directory.
|
||||
# When resolved_base already ends with a separator (e.g. the filesystem
|
||||
# root "/"), appending os.sep would double it ("//"), so use the base
|
||||
# as-is in that case.
|
||||
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
|
||||
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
|
||||
raise ValueError(
|
||||
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
|
||||
f"outside the allowed directory. "
|
||||
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Path '{format_path_for_display(resolved_path, primary_root)}' is "
|
||||
f"outside the allowed directories. "
|
||||
f"Add the directory via {_ALLOWED_DIRS_ENV}, or set "
|
||||
f"{_UNSAFE_PATHS_ENV}=true to bypass this check."
|
||||
)
|
||||
return resolved_path
|
||||
|
||||
|
||||
def validate_directory_path(
|
||||
path: str,
|
||||
base_dir: str | None = None,
|
||||
*,
|
||||
allowed_dirs: list[str] | None = None,
|
||||
) -> str:
|
||||
def validate_directory_path(path: str, base_dir: str | None = None) -> str:
|
||||
"""Validate that a directory path is safe to read.
|
||||
|
||||
Same as :func:`validate_file_path` but also checks that the path
|
||||
@@ -194,16 +107,15 @@ def validate_directory_path(
|
||||
|
||||
Args:
|
||||
path: The directory path to validate.
|
||||
base_dir: Primary allowed root. Defaults to ``os.getcwd()``.
|
||||
allowed_dirs: Additional allowed root directories.
|
||||
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
|
||||
|
||||
Returns:
|
||||
The resolved, validated absolute path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path escapes every allowed directory or is not a directory.
|
||||
ValueError: If the path escapes the allowed directory or is not a directory.
|
||||
"""
|
||||
validated = validate_file_path(path, base_dir, allowed_dirs=allowed_dirs)
|
||||
validated = validate_file_path(path, base_dir)
|
||||
if not os.path.isdir(validated):
|
||||
raise ValueError(f"Path '{validated}' is not a directory.")
|
||||
return validated
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
@@ -7,7 +8,6 @@ from pydantic import BaseModel
|
||||
from crewai_tools.security.safe_path import (
|
||||
format_error_for_display,
|
||||
format_path_for_display,
|
||||
validate_file_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,27 +41,22 @@ class FileWriterTool(BaseTool):
|
||||
|
||||
filepath = os.path.join(directory, filename)
|
||||
|
||||
# Confine the resolved write target to an allow-listed root
|
||||
# (cwd + CREWAI_TOOLS_ALLOWED_DIRS), NOT merely inside the
|
||||
# caller-supplied `directory`. That value is itself untrusted when
|
||||
# an LLM tool call chooses it, so checking containment against it
|
||||
# would let an agent write anywhere (e.g. ~/.ssh/authorized_keys).
|
||||
# validate_file_path resolves symlinks and ".." before checking.
|
||||
try:
|
||||
real_filepath = validate_file_path(filepath)
|
||||
except ValueError as e:
|
||||
return f"Error: {format_error_for_display(e)}"
|
||||
|
||||
real_directory = os.path.dirname(real_filepath)
|
||||
display_filepath = format_path_for_display(real_filepath, real_directory)
|
||||
|
||||
# A target that resolves to an existing directory is not a valid
|
||||
# file destination.
|
||||
if os.path.isdir(real_filepath):
|
||||
return (
|
||||
"Error: Invalid file path — the target must be a file, "
|
||||
"not a directory."
|
||||
)
|
||||
# Prevent path traversal: the resolved path must be strictly inside
|
||||
# filename, and symlink escapes regardless of how directory is set.
|
||||
# is_relative_to() does a proper path-component comparison that is
|
||||
# safe on case-insensitive filesystems and avoids the "// " edge case
|
||||
# We also reject the case where filepath resolves to the directory
|
||||
# itself, since that is not a valid file target.
|
||||
real_directory = Path(directory).resolve()
|
||||
real_filepath = Path(filepath).resolve()
|
||||
display_filepath = format_path_for_display(
|
||||
str(real_filepath), str(real_directory)
|
||||
)
|
||||
if (
|
||||
not real_filepath.is_relative_to(real_directory)
|
||||
or real_filepath == real_directory
|
||||
):
|
||||
return "Error: Invalid file path — the filename must not escape the target directory."
|
||||
|
||||
if kwargs.get("directory"):
|
||||
os.makedirs(real_directory, exist_ok=True)
|
||||
|
||||
@@ -17,23 +17,12 @@ def temp_env():
|
||||
test_file = "test.txt"
|
||||
test_content = "Hello, World!"
|
||||
|
||||
# FileWriterTool confines writes to an allow-listed root (cwd plus
|
||||
# CREWAI_TOOLS_ALLOWED_DIRS). Explicitly permit this temp dir — this is the
|
||||
# supported way for a developer to widen the write scope to an external
|
||||
# directory, and lets the happy-path tests below write into it.
|
||||
prev_allowed = os.environ.get("CREWAI_TOOLS_ALLOWED_DIRS")
|
||||
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = temp_dir
|
||||
|
||||
yield {
|
||||
"temp_dir": temp_dir,
|
||||
"test_file": test_file,
|
||||
"test_content": test_content,
|
||||
}
|
||||
|
||||
if prev_allowed is None:
|
||||
os.environ.pop("CREWAI_TOOLS_ALLOWED_DIRS", None)
|
||||
else:
|
||||
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = prev_allowed
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -207,24 +196,3 @@ def test_blocks_symlink_escape(tool, temp_env):
|
||||
assert not os.path.exists(outside_file)
|
||||
finally:
|
||||
shutil.rmtree(outside_dir, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
def test_blocks_unbounded_directory_arg(tool, temp_env):
|
||||
# The core fix: the `directory` argument is itself untrusted (LLM-chosen).
|
||||
# A directory outside the allow-list must be rejected even when filename
|
||||
# is benign — previously this let an agent write anywhere on disk
|
||||
# (e.g. ~/.ssh/authorized_keys).
|
||||
outside_dir = tempfile.mkdtemp() # NOT added to CREWAI_TOOLS_ALLOWED_DIRS
|
||||
outside_file = os.path.join(outside_dir, "test.txt")
|
||||
try:
|
||||
result = tool._run(
|
||||
filename="test.txt",
|
||||
directory=outside_dir,
|
||||
content="should not be written",
|
||||
overwrite=True,
|
||||
)
|
||||
assert "Error" in result
|
||||
assert not os.path.exists(outside_file)
|
||||
finally:
|
||||
shutil.rmtree(outside_dir, ignore_errors=True)
|
||||
|
||||
@@ -32,12 +32,12 @@ class TestValidateFilePath:
|
||||
|
||||
def test_rejects_dotdot_traversal(self, tmp_path):
|
||||
"""Reject ../ traversal that escapes base_dir."""
|
||||
with pytest.raises(ValueError, match="outside the allowed director"):
|
||||
with pytest.raises(ValueError, match="outside the allowed directory"):
|
||||
validate_file_path("../../etc/passwd", str(tmp_path))
|
||||
|
||||
def test_rejects_absolute_path_outside_base(self, tmp_path):
|
||||
"""Reject absolute path outside base_dir."""
|
||||
with pytest.raises(ValueError, match="outside the allowed directories"):
|
||||
with pytest.raises(ValueError, match="outside the allowed directory"):
|
||||
validate_file_path("/etc/passwd", str(tmp_path))
|
||||
|
||||
def test_allows_absolute_path_inside_base(self, tmp_path):
|
||||
@@ -50,7 +50,7 @@ class TestValidateFilePath:
|
||||
"""Reject symlinks that point outside base_dir."""
|
||||
link = tmp_path / "sneaky_link"
|
||||
os.symlink("/etc/passwd", str(link))
|
||||
with pytest.raises(ValueError, match="outside the allowed directories"):
|
||||
with pytest.raises(ValueError, match="outside the allowed directory"):
|
||||
validate_file_path("sneaky_link", str(tmp_path))
|
||||
|
||||
def test_defaults_to_cwd(self):
|
||||
@@ -113,7 +113,7 @@ class TestValidateDirectoryPath:
|
||||
validate_directory_path("file.txt", str(tmp_path))
|
||||
|
||||
def test_rejects_traversal(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="outside the allowed directories"):
|
||||
with pytest.raises(ValueError, match="outside the allowed directory"):
|
||||
validate_directory_path("../../", str(tmp_path))
|
||||
|
||||
|
||||
@@ -191,95 +191,3 @@ class TestValidateUrl:
|
||||
# file:// would normally be blocked
|
||||
result = validate_url("file:///etc/passwd")
|
||||
assert result == "file:///etc/passwd"
|
||||
|
||||
|
||||
|
||||
class TestAllowList:
|
||||
"""Tests for the configurable deny-by-default allow-list of roots."""
|
||||
|
||||
def test_param_extends_allowed_roots(self, tmp_path):
|
||||
"""A directory passed via allowed_dirs is permitted."""
|
||||
extra = tmp_path / "extra"
|
||||
extra.mkdir()
|
||||
(extra / "data.txt").touch()
|
||||
result = validate_file_path(
|
||||
str(extra / "data.txt"),
|
||||
base_dir=str(tmp_path / "base"),
|
||||
allowed_dirs=[str(extra)],
|
||||
)
|
||||
assert result == str(extra / "data.txt")
|
||||
|
||||
def test_env_extends_allowed_roots(self, tmp_path, monkeypatch):
|
||||
"""A directory listed in CREWAI_TOOLS_ALLOWED_DIRS is permitted."""
|
||||
base = tmp_path / "base"
|
||||
base.mkdir()
|
||||
extra = tmp_path / "extra"
|
||||
extra.mkdir()
|
||||
(extra / "data.txt").touch()
|
||||
monkeypatch.setenv("CREWAI_TOOLS_ALLOWED_DIRS", str(extra))
|
||||
result = validate_file_path(str(extra / "data.txt"), base_dir=str(base))
|
||||
assert result == str(extra / "data.txt")
|
||||
|
||||
def test_denied_without_allow_listing(self, tmp_path, monkeypatch):
|
||||
"""The same external dir is rejected when not allow-listed."""
|
||||
base = tmp_path / "base"
|
||||
base.mkdir()
|
||||
extra = tmp_path / "extra"
|
||||
extra.mkdir()
|
||||
(extra / "data.txt").touch()
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
|
||||
with pytest.raises(ValueError, match="outside the allowed directories"):
|
||||
validate_file_path(str(extra / "data.txt"), base_dir=str(base))
|
||||
|
||||
def test_multiple_env_roots(self, tmp_path, monkeypatch):
|
||||
"""Multiple os.pathsep-separated roots are each honored."""
|
||||
base = tmp_path / "base"
|
||||
base.mkdir()
|
||||
a = tmp_path / "a"
|
||||
a.mkdir()
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(a / "fa.txt").touch()
|
||||
(b / "fb.txt").touch()
|
||||
monkeypatch.setenv(
|
||||
"CREWAI_TOOLS_ALLOWED_DIRS", os.pathsep.join([str(a), str(b)])
|
||||
)
|
||||
assert validate_file_path(str(a / "fa.txt"), base_dir=str(base)) == str(
|
||||
a / "fa.txt"
|
||||
)
|
||||
assert validate_file_path(str(b / "fb.txt"), base_dir=str(base)) == str(
|
||||
b / "fb.txt"
|
||||
)
|
||||
|
||||
def test_cwd_root_default_is_not_an_allowed_root(self, tmp_path, monkeypatch):
|
||||
"""An unconfigured cwd of '/' must not open the whole filesystem.
|
||||
|
||||
Regression for the deny-by-default allow-list silently defaulting to the
|
||||
filesystem root in containers started without a WORKDIR.
|
||||
"""
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", raising=False)
|
||||
monkeypatch.setattr(os, "getcwd", lambda: os.sep)
|
||||
with pytest.raises(ValueError, match="filesystem root"):
|
||||
validate_file_path("/etc/passwd")
|
||||
|
||||
def test_cwd_root_with_explicit_allowed_dirs_confines(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""With cwd '/', confinement falls back to the explicit allow-list."""
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
|
||||
monkeypatch.setattr(os, "getcwd", lambda: os.sep)
|
||||
(tmp_path / "data.txt").touch()
|
||||
assert validate_file_path(
|
||||
str(tmp_path / "data.txt"), allowed_dirs=[str(tmp_path)]
|
||||
) == str(tmp_path / "data.txt")
|
||||
with pytest.raises(ValueError, match="outside the allowed directories"):
|
||||
validate_file_path("/etc/passwd", allowed_dirs=[str(tmp_path)])
|
||||
|
||||
def test_explicit_base_dir_root_is_opt_in(self, monkeypatch):
|
||||
"""An explicit base_dir of '/' is honored as a deliberate opt-in."""
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
|
||||
monkeypatch.delenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", raising=False)
|
||||
assert validate_file_path("/etc/passwd", base_dir=os.sep) == os.path.realpath(
|
||||
"/etc/passwd"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
|
||||
from crewai.flow.dsl._utils import (
|
||||
P,
|
||||
R,
|
||||
_merge_flow_method_definition,
|
||||
_method_action,
|
||||
_set_flow_method_definition,
|
||||
)
|
||||
from crewai.flow.flow_definition import FlowMethodDefinition
|
||||
from crewai.flow.flow_wrappers import ListenMethod
|
||||
@@ -45,7 +45,7 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
|
||||
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
|
||||
wrapper = ListenMethod(func)
|
||||
|
||||
_set_flow_method_definition(
|
||||
_merge_flow_method_definition(
|
||||
wrapper,
|
||||
FlowMethodDefinition(
|
||||
do=_method_action(func),
|
||||
|
||||
@@ -19,8 +19,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
|
||||
from crewai.flow.dsl._utils import (
|
||||
P,
|
||||
R,
|
||||
_merge_flow_method_definition,
|
||||
_method_action,
|
||||
_set_flow_method_definition,
|
||||
)
|
||||
from crewai.flow.flow_definition import FlowMethodDefinition
|
||||
from crewai.flow.flow_wrappers import RouterMethod
|
||||
@@ -95,7 +95,7 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]:
|
||||
|
||||
|
||||
def router(
|
||||
condition: FlowTrigger,
|
||||
condition: FlowTrigger | None = None,
|
||||
*,
|
||||
emit: Sequence[str] | str | None = None,
|
||||
) -> FlowMethodDecorator:
|
||||
@@ -107,6 +107,7 @@ def router(
|
||||
|
||||
Args:
|
||||
condition: Specifies when the router should execute. Can be:
|
||||
- None: no listen trigger, used when stacking with @start() or @listen()
|
||||
- str: Route label or method name that triggers this router
|
||||
- FlowCondition: Result from or_() or and_(), including nested conditions
|
||||
- Flow method reference: A method whose completion triggers this router
|
||||
@@ -146,14 +147,17 @@ def router(
|
||||
else:
|
||||
router_events = _get_router_return_events(func) or []
|
||||
|
||||
_set_flow_method_definition(
|
||||
method_definition_kwargs: dict[str, Any] = {
|
||||
"do": _method_action(func),
|
||||
"router": True,
|
||||
"emit": router_events or None,
|
||||
}
|
||||
if condition is not None:
|
||||
method_definition_kwargs["listen"] = _to_definition_condition(condition)
|
||||
|
||||
_merge_flow_method_definition(
|
||||
wrapper,
|
||||
FlowMethodDefinition(
|
||||
do=_method_action(func),
|
||||
listen=_to_definition_condition(condition),
|
||||
router=True,
|
||||
emit=router_events or None,
|
||||
),
|
||||
FlowMethodDefinition(**method_definition_kwargs),
|
||||
)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
|
||||
from crewai.flow.dsl._utils import (
|
||||
P,
|
||||
R,
|
||||
_merge_flow_method_definition,
|
||||
_method_action,
|
||||
_set_flow_method_definition,
|
||||
)
|
||||
from crewai.flow.flow_definition import FlowMethodDefinition
|
||||
from crewai.flow.flow_wrappers import StartMethod
|
||||
@@ -54,7 +54,7 @@ def start(
|
||||
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
|
||||
wrapper = StartMethod(func)
|
||||
|
||||
_set_flow_method_definition(
|
||||
_merge_flow_method_definition(
|
||||
wrapper,
|
||||
FlowMethodDefinition(
|
||||
do=_method_action(func),
|
||||
|
||||
@@ -106,6 +106,25 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_flow_method_definition(
|
||||
wrapper: FlowMethod[P, R],
|
||||
definition: FlowMethodDefinition,
|
||||
) -> None:
|
||||
existing = _get_flow_method_definition(wrapper)
|
||||
if existing is None:
|
||||
_set_flow_method_definition(wrapper, definition)
|
||||
return
|
||||
|
||||
updates = {
|
||||
field_name: getattr(definition, field_name)
|
||||
for field_name in definition.model_fields_set
|
||||
}
|
||||
_set_flow_method_definition(
|
||||
wrapper,
|
||||
existing.model_copy(deep=True, update=updates),
|
||||
)
|
||||
|
||||
|
||||
def _is_json_serializable(value: Any) -> bool:
|
||||
try:
|
||||
json.dumps(value)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Flow Structure: the serializable, language-agnostic Flow contract.
|
||||
"""Flow Definition: the serializable, declarative Flow contract.
|
||||
|
||||
Defines :class:`FlowDefinition` and its sub-models — a static, textual
|
||||
Defines :class:`FlowDefinition` and its sub-models — a static, declarative
|
||||
(JSON/YAML) representation of a Flow: its methods, trigger conditions,
|
||||
state, and configuration. It is independent of the Python authoring
|
||||
layer that may have produced it and of the engine that runs it (see
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, TypeAlias, cast
|
||||
|
||||
@@ -18,6 +19,7 @@ from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
field_serializer,
|
||||
model_validator,
|
||||
)
|
||||
@@ -406,10 +408,19 @@ class FlowCrewActionDefinition(BaseModel):
|
||||
)
|
||||
|
||||
call: Literal["crew"] = Field(
|
||||
description="Action discriminator. Use crew to run an inline Crew definition.",
|
||||
description=(
|
||||
"Action discriminator. Use crew to run an inline or referenced Crew "
|
||||
"definition."
|
||||
),
|
||||
examples=["crew"],
|
||||
)
|
||||
with_: CrewDefinition = Field(
|
||||
from_declaration: str | None = Field(
|
||||
default=None,
|
||||
description="Path to a JSON/JSONC Crew declaration file or folder.",
|
||||
examples=["crews/research_crew"],
|
||||
)
|
||||
with_: CrewDefinition | None = Field(
|
||||
default=None,
|
||||
alias="with",
|
||||
description="Inline Crew definition to load and execute for this action.",
|
||||
examples=[
|
||||
@@ -430,10 +441,26 @@ class FlowCrewActionDefinition(BaseModel):
|
||||
"agent": "researcher",
|
||||
}
|
||||
],
|
||||
"inputs": {"topic": "${state.topic}"},
|
||||
}
|
||||
],
|
||||
)
|
||||
inputs: dict[str, ExpressionData] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Input overrides passed to the Crew. String values are evaluated as CEL "
|
||||
"only when the trimmed value starts with ${ and ends with }; all other "
|
||||
"values are literal."
|
||||
),
|
||||
examples=[{"topic": "${state.topic}"}],
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_crew_source(self) -> FlowCrewActionDefinition:
|
||||
if bool(self.from_declaration) == (self.with_ is not None):
|
||||
raise ValueError(
|
||||
"crew action requires exactly one of from_declaration or with"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class FlowAgentActionDefinition(BaseModel):
|
||||
@@ -684,10 +711,12 @@ class FlowDefinition(BaseModel):
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
_source_path: Path | None = PrivateAttr(default=None)
|
||||
|
||||
schema_: Literal["crewai.flow/v1"] = Field(
|
||||
default="crewai.flow/v1",
|
||||
alias="schema",
|
||||
description="Flow Definition schema identifier and version.",
|
||||
description="Declarative Flow schema identifier and version.",
|
||||
examples=["crewai.flow/v1"],
|
||||
)
|
||||
name: str = Field(
|
||||
@@ -764,29 +793,45 @@ class FlowDefinition(BaseModel):
|
||||
allow_unicode=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def source_path(self) -> Path | None:
|
||||
"""Original definition file path, when loaded from a file."""
|
||||
return self._source_path
|
||||
|
||||
@property
|
||||
def source_dir(self) -> Path | None:
|
||||
"""Directory used to resolve relative paths in the definition."""
|
||||
if self._source_path is None:
|
||||
return None
|
||||
return self._source_path.parent
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
|
||||
def from_dict(
|
||||
cls, data: dict[str, Any], *, source_path: Path | None = None
|
||||
) -> FlowDefinition:
|
||||
"""Load a definition from a dictionary."""
|
||||
definition = cls.model_validate(data)
|
||||
if source_path is not None:
|
||||
definition._source_path = source_path.expanduser().resolve()
|
||||
log_flow_definition_issues(definition)
|
||||
return definition
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> FlowDefinition:
|
||||
def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
|
||||
"""Load a definition from JSON."""
|
||||
return cls.from_dict(json.loads(data))
|
||||
return cls.from_dict(json.loads(data), source_path=source_path)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, data: str) -> FlowDefinition:
|
||||
def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
|
||||
"""Load a definition from YAML."""
|
||||
loaded = yaml.safe_load(data) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("Flow definition YAML must contain a mapping")
|
||||
return cls.from_dict(loaded)
|
||||
return cls.from_dict(loaded, source_path=source_path)
|
||||
|
||||
@classmethod
|
||||
def json_schema(cls) -> dict[str, Any]:
|
||||
"""Return the JSON Schema for the Flow Definition contract."""
|
||||
"""Return the JSON Schema for the declarative Flow contract."""
|
||||
return cls.model_json_schema(by_alias=True)
|
||||
|
||||
|
||||
@@ -826,10 +871,16 @@ def _validate_action_cel(
|
||||
return
|
||||
|
||||
if isinstance(action, FlowCrewActionDefinition):
|
||||
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
|
||||
allowed_roots=allowed_roots,
|
||||
source=f"{path}.with.inputs",
|
||||
)
|
||||
if action.with_ is not None:
|
||||
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
|
||||
allowed_roots=allowed_roots,
|
||||
source=f"{path}.with.inputs",
|
||||
)
|
||||
if action.inputs is not None:
|
||||
Expression(cast(ExpressionData, action.inputs)).validate_template(
|
||||
allowed_roots=allowed_roots,
|
||||
source=f"{path}.inputs",
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(action, FlowAgentActionDefinition):
|
||||
@@ -870,14 +921,6 @@ def _validate_action_cel(
|
||||
def log_flow_definition_issues(definition: FlowDefinition) -> None:
|
||||
for method_name, method in definition.methods.items():
|
||||
path = f"methods.{method_name}"
|
||||
if method.router and not method.is_start and method.listen is None:
|
||||
_log_flow_definition_issue(
|
||||
definition.name,
|
||||
code="router_without_trigger",
|
||||
severity="error",
|
||||
path=path,
|
||||
message="router: true requires either start or listen",
|
||||
)
|
||||
if method.emit and not method.router:
|
||||
_log_flow_definition_issue(
|
||||
definition.name,
|
||||
|
||||
@@ -3007,6 +3007,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
"""
|
||||
# First, handle routers repeatedly until no router triggers anymore
|
||||
router_results = []
|
||||
router_result_payloads: dict[str, Any] = {}
|
||||
router_result_to_feedback: dict[
|
||||
str, Any
|
||||
] = {} # Map outcome -> HumanFeedbackResult
|
||||
@@ -3044,6 +3045,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
router_result_str = str(router_result)
|
||||
router_result_event = FlowMethodName(router_result_str)
|
||||
router_results.append(router_result_event)
|
||||
router_result_payloads[router_result_str] = (
|
||||
self.last_human_feedback
|
||||
if self.last_human_feedback is not None
|
||||
else router_result
|
||||
)
|
||||
|
||||
if self.last_human_feedback is not None:
|
||||
router_result_to_feedback[router_result_str] = (
|
||||
@@ -3064,7 +3070,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
current_trigger, router_only=False
|
||||
)
|
||||
if listeners_triggered:
|
||||
listener_result = router_result_to_feedback.get(
|
||||
listener_result = router_result_payloads.get(
|
||||
str(current_trigger), result
|
||||
)
|
||||
racing_group = self._get_racing_group_for_listeners(
|
||||
|
||||
@@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
|
||||
import contextvars
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
from crewai.flow.expressions import Expression, ExpressionData
|
||||
@@ -128,16 +129,34 @@ class CrewAction:
|
||||
self.definition = definition
|
||||
|
||||
async def run(self, *_args: Any, **kwargs: Any) -> Any:
|
||||
from crewai.project.crew_loader import load_crew_from_definition
|
||||
from crewai.project.crew_loader import load_crew, load_crew_from_definition
|
||||
|
||||
local_context = _pop_local_context(kwargs)
|
||||
crew_definition = self.definition.with_
|
||||
if self.definition.from_declaration is not None:
|
||||
crew, default_inputs = load_crew(
|
||||
_resolve_crew_declaration(
|
||||
self.definition.from_declaration,
|
||||
base_dir=self.flow._definition.source_dir,
|
||||
)
|
||||
)
|
||||
input_template = {**default_inputs, **(self.definition.inputs or {})}
|
||||
else:
|
||||
crew_definition = self.definition.with_
|
||||
if crew_definition is None:
|
||||
raise ValueError(
|
||||
"crew action requires exactly one of from_declaration or with"
|
||||
)
|
||||
input_template = {
|
||||
**crew_definition.inputs,
|
||||
**(self.definition.inputs or {}),
|
||||
}
|
||||
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
|
||||
|
||||
inputs = Expression.from_flow(
|
||||
cast(ExpressionData, crew_definition.inputs),
|
||||
cast(ExpressionData, input_template),
|
||||
self.flow,
|
||||
local_context=local_context,
|
||||
).render_template()
|
||||
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
|
||||
return await crew.kickoff_async(inputs=inputs)
|
||||
|
||||
|
||||
@@ -359,3 +378,20 @@ def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None:
|
||||
if not isinstance(local_context, dict):
|
||||
raise TypeError("flow definition local context must be a mapping")
|
||||
return cast(LocalContext, local_context)
|
||||
|
||||
|
||||
def _resolve_crew_declaration(
|
||||
from_declaration: str, *, base_dir: Path | None = None
|
||||
) -> Path:
|
||||
path = Path(from_declaration).expanduser()
|
||||
if not path.is_absolute() and base_dir is not None:
|
||||
path = base_dir / path
|
||||
if not path.is_dir():
|
||||
return path
|
||||
|
||||
for name in ("crew.jsonc", "crew.json"):
|
||||
candidate = path / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
return path / "crew.jsonc"
|
||||
|
||||
@@ -386,6 +386,54 @@ def test_router_runtime_uses_flow_definition_without_legacy_router_metadata():
|
||||
assert execution_order == ["begin", "decide", "handle_left"]
|
||||
|
||||
|
||||
def test_start_router_runtime_routes_public_dsl_return_value():
|
||||
execution_order = []
|
||||
|
||||
class StartRouterFlow(Flow):
|
||||
@start()
|
||||
@router(emit=["continue"])
|
||||
def decide(self):
|
||||
execution_order.append("decide")
|
||||
return "continue"
|
||||
|
||||
@listen("continue")
|
||||
def handle_continue(self, result):
|
||||
execution_order.append(f"handle_continue:{result}")
|
||||
return "done"
|
||||
|
||||
assert StartRouterFlow().kickoff() == "done"
|
||||
assert execution_order == ["decide", "handle_continue:continue"]
|
||||
|
||||
|
||||
def test_start_router_runtime_chains_to_stacked_listener_router():
|
||||
execution_order = []
|
||||
|
||||
class ChainedStartRouterFlow(Flow):
|
||||
@start()
|
||||
@router(emit=["approved", "not_approved"])
|
||||
def first_router(self):
|
||||
execution_order.append("first_router")
|
||||
return "approved"
|
||||
|
||||
@listen("approved")
|
||||
@router(emit=["second_approval", "not_approved"])
|
||||
def second_router(self):
|
||||
execution_order.append("second_router")
|
||||
return "second_approval"
|
||||
|
||||
@listen("second_approval")
|
||||
def handle_second_approval(self, result):
|
||||
execution_order.append(f"handle_second_approval:{result}")
|
||||
return "done"
|
||||
|
||||
assert ChainedStartRouterFlow().kickoff() == "done"
|
||||
assert execution_order == [
|
||||
"first_router",
|
||||
"second_router",
|
||||
"handle_second_approval:second_approval",
|
||||
]
|
||||
|
||||
|
||||
def test_router_falsy_result_emits_runtime_event():
|
||||
execution_order = []
|
||||
|
||||
|
||||
@@ -565,6 +565,54 @@ def test_flow_definition_classifies_start_router_from_human_feedback_emit():
|
||||
assert entry_point.emit is None
|
||||
|
||||
|
||||
def test_flow_definition_classifies_public_dsl_start_router():
|
||||
class StartRouterFlow(Flow):
|
||||
@start()
|
||||
@router(emit=["continue", "stop"])
|
||||
def entry_point(self):
|
||||
return "continue"
|
||||
|
||||
@router(emit=["resume"])
|
||||
@start()
|
||||
def alternate_entry_point(self):
|
||||
return "resume"
|
||||
|
||||
entry_point = StartRouterFlow.flow_definition().methods["entry_point"]
|
||||
alternate_entry_point = StartRouterFlow.flow_definition().methods[
|
||||
"alternate_entry_point"
|
||||
]
|
||||
|
||||
assert entry_point.is_start is True
|
||||
assert entry_point.router is True
|
||||
assert entry_point.listen is None
|
||||
assert entry_point.emit == ["continue", "stop"]
|
||||
assert alternate_entry_point.is_start is True
|
||||
assert alternate_entry_point.router is True
|
||||
assert alternate_entry_point.listen is None
|
||||
assert alternate_entry_point.emit == ["resume"]
|
||||
|
||||
|
||||
def test_flow_definition_merges_stacked_listen_router():
|
||||
class ChainedRouterFlow(Flow):
|
||||
@start()
|
||||
@router(emit=["approved", "not_approved"])
|
||||
def first_router(self):
|
||||
return "approved"
|
||||
|
||||
@listen("approved")
|
||||
@router(emit=["second_approval", "not_approved"])
|
||||
def second_router(self):
|
||||
return "second_approval"
|
||||
|
||||
methods = ChainedRouterFlow.flow_definition().methods
|
||||
|
||||
assert methods["first_router"].is_start is True
|
||||
assert methods["first_router"].listen is None
|
||||
assert methods["second_router"].router is True
|
||||
assert methods["second_router"].listen == "approved"
|
||||
assert methods["second_router"].emit == ["second_approval", "not_approved"]
|
||||
|
||||
|
||||
def test_flow_definition_round_trips_json_and_yaml():
|
||||
class RoundTripFlow(Flow):
|
||||
@start()
|
||||
@@ -883,7 +931,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
|
||||
assert "diagnostics" not in definition.to_dict()
|
||||
|
||||
|
||||
def test_router_start_false_without_listen_logs_missing_trigger(caplog):
|
||||
def test_router_start_false_without_listen_is_allowed(caplog):
|
||||
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
|
||||
|
||||
flow_definition.FlowDefinition.from_dict(
|
||||
@@ -901,12 +949,7 @@ def test_router_start_false_without_listen_logs_missing_trigger(caplog):
|
||||
}
|
||||
)
|
||||
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and "router_without_trigger" in record.message
|
||||
and "methods.decision" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_router_human_feedback_preserves_existing_router_metadata():
|
||||
@@ -1048,7 +1091,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
|
||||
assert set(child_definition.methods) == {"child_step"}
|
||||
|
||||
|
||||
def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
|
||||
def test_flow_definition_allows_router_without_trigger(caplog):
|
||||
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
|
||||
|
||||
flow_definition.FlowDefinition.from_dict(
|
||||
@@ -1065,9 +1108,11 @@ def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog
|
||||
}
|
||||
)
|
||||
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and "LoadedFlow" in record.message
|
||||
and "router_without_trigger" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
class StandaloneRouterFlow(Flow):
|
||||
@router(emit=["continue"])
|
||||
def decision(self):
|
||||
return "continue"
|
||||
|
||||
StandaloneRouterFlow.flow_definition()
|
||||
|
||||
assert not caplog.records
|
||||
|
||||
@@ -1005,8 +1005,8 @@ methods:
|
||||
description: Research {topic}
|
||||
expected_output: Findings about {topic}
|
||||
agent: researcher
|
||||
inputs:
|
||||
topic: "${state.topic}"
|
||||
inputs:
|
||||
topic: "${state.topic}"
|
||||
start: true
|
||||
"""
|
||||
|
||||
@@ -1020,6 +1020,156 @@ methods:
|
||||
}
|
||||
|
||||
|
||||
def test_crew_action_runs_crew_from_declaration(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
):
|
||||
from crewai import Crew
|
||||
|
||||
project_root = tmp_path / "project"
|
||||
crew_root = project_root / "crews" / "research_crew"
|
||||
agents_root = crew_root / "agents"
|
||||
agents_root.mkdir(parents=True)
|
||||
(agents_root / "researcher.jsonc").write_text(
|
||||
"""
|
||||
{
|
||||
"role": "Researcher",
|
||||
"goal": "Research {topic}",
|
||||
"backstory": "Knows things."
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(crew_root / "crew.jsonc").write_text(
|
||||
"""
|
||||
{
|
||||
"name": "referenced_research",
|
||||
"agents": ["researcher"],
|
||||
"tasks": [
|
||||
{
|
||||
"name": "research_task",
|
||||
"description": "Research {topic}",
|
||||
"expected_output": "Findings about {topic}",
|
||||
"agent": "researcher"
|
||||
}
|
||||
],
|
||||
"inputs": {
|
||||
"topic": "Default topic",
|
||||
"audience": "developers"
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def fake_kickoff_async(
|
||||
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"crew": self.name,
|
||||
"tasks": [task.description for task in self.tasks],
|
||||
"inputs": inputs,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
|
||||
monkeypatch.chdir(project_root)
|
||||
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: CrewFlow
|
||||
methods:
|
||||
research:
|
||||
do:
|
||||
call: crew
|
||||
from_declaration: crews/research_crew
|
||||
inputs:
|
||||
topic: "${state.topic}"
|
||||
start: true
|
||||
"""
|
||||
|
||||
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
|
||||
|
||||
assert flow.kickoff(inputs={"topic": "AI"}) == {
|
||||
"crew": "referenced_research",
|
||||
"tasks": ["Research {topic}"],
|
||||
"inputs": {"topic": "AI", "audience": "developers"},
|
||||
}
|
||||
|
||||
|
||||
def test_crew_action_from_declaration_resolves_relative_to_flow_file(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
):
|
||||
from crewai import Crew
|
||||
|
||||
project_root = tmp_path / "project"
|
||||
crew_root = project_root / "crews" / "research_crew"
|
||||
agents_root = crew_root / "agents"
|
||||
agents_root.mkdir(parents=True)
|
||||
(agents_root / "researcher.jsonc").write_text(
|
||||
"""
|
||||
{
|
||||
"role": "Researcher",
|
||||
"goal": "Research {topic}",
|
||||
"backstory": "Knows things."
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(crew_root / "crew.jsonc").write_text(
|
||||
"""
|
||||
{
|
||||
"name": "relative_research",
|
||||
"agents": ["researcher"],
|
||||
"tasks": [
|
||||
{
|
||||
"description": "Research {topic}",
|
||||
"expected_output": "Findings about {topic}",
|
||||
"agent": "researcher"
|
||||
}
|
||||
],
|
||||
"inputs": {
|
||||
"topic": "Default topic"
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def fake_kickoff_async(
|
||||
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
return {"crew": self.name, "inputs": inputs}
|
||||
|
||||
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
|
||||
|
||||
flow_path = project_root / "flow.yaml"
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: CrewFlow
|
||||
methods:
|
||||
research:
|
||||
do:
|
||||
call: crew
|
||||
from_declaration: crews/research_crew
|
||||
inputs:
|
||||
topic: "${state.topic}"
|
||||
start: true
|
||||
"""
|
||||
flow_path.write_text(yaml_str, encoding="utf-8")
|
||||
|
||||
other_cwd = tmp_path / "other"
|
||||
other_cwd.mkdir()
|
||||
monkeypatch.chdir(other_cwd)
|
||||
|
||||
flow = Flow.from_definition(
|
||||
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
|
||||
)
|
||||
|
||||
assert flow.kickoff(inputs={"topic": "AI"}) == {
|
||||
"crew": "relative_research",
|
||||
"inputs": {"topic": "AI"},
|
||||
}
|
||||
|
||||
|
||||
def test_crew_action_round_trips_with_inline_definition():
|
||||
definition = FlowDefinition.from_dict(
|
||||
{
|
||||
@@ -1047,8 +1197,8 @@ def test_crew_action_round_trips_with_inline_definition():
|
||||
"agent": "researcher",
|
||||
}
|
||||
],
|
||||
"inputs": {"topic": "${state.topic}"},
|
||||
},
|
||||
"inputs": {"topic": "${state.topic}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -1062,6 +1212,9 @@ def test_crew_action_round_trips_with_inline_definition():
|
||||
]["role"]
|
||||
== "Researcher"
|
||||
)
|
||||
assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == {
|
||||
"topic": "${state.topic}"
|
||||
}
|
||||
|
||||
|
||||
def test_crew_action_normalizes_named_agent_list_definition():
|
||||
@@ -1162,7 +1315,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition():
|
||||
)
|
||||
|
||||
|
||||
def test_crew_action_rejects_ref():
|
||||
def test_crew_action_rejects_python_ref_field():
|
||||
with pytest.raises(ValidationError, match="ref"):
|
||||
FlowDefinition.from_dict(
|
||||
{
|
||||
@@ -1174,7 +1327,6 @@ def test_crew_action_rejects_ref():
|
||||
"do": {
|
||||
"call": "crew",
|
||||
"ref": "project.crew:build_crew",
|
||||
"with": {"inputs": {"topic": "AI"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ dev = [
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-subprocess==1.5.3",
|
||||
"vcrpy==7.0.0", # pinned, less versions break pytest-recording
|
||||
"vcrpy==8.2.1", # pinned, lower versions break pytest-recording
|
||||
"pytest-recording==0.13.4",
|
||||
"pytest-randomly==4.0.1",
|
||||
"pytest-timeout==2.4.0",
|
||||
@@ -171,8 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0."
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
|
||||
# These security fixes are newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" }
|
||||
|
||||
# composio-core pins rich<14 but textual requires rich>=14.
|
||||
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
|
||||
@@ -188,7 +188,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
|
||||
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
|
||||
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
|
||||
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
|
||||
# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+.
|
||||
# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure)
|
||||
# and GHSA-f4xh-w4cj-qxq8; force 0.8.18+.
|
||||
# authlib <1.6.12 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage) and PYSEC-2026-188.
|
||||
# pip 26.1.1 has PYSEC-2026-196; force 26.1.2+.
|
||||
# aiohttp <=3.13.x has GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg; fixed in 3.14.0; force 3.14.0+.
|
||||
@@ -196,6 +197,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
|
||||
# pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected.
|
||||
# paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core.
|
||||
# starlette <1.3.1 has PYSEC-2026-161, GHSA-jp82-jpqv-5vv3, and GHSA-82w8-qh3p-5jfq. Transitive via fastapi.
|
||||
# msgpack <1.2.1 has GHSA-6v7p-g79w-8964; transitive via pip-audit[filecache].
|
||||
# pydantic-settings <2.14.2 has GHSA-4xgf-cpjx-pc3j.
|
||||
# Keep OpenAI on the SDK range required by CrewAI when transitive dependencies
|
||||
# loosen or pin their own lower versions.
|
||||
override-dependencies = [
|
||||
@@ -212,16 +215,17 @@ override-dependencies = [
|
||||
"uv>=0.11.15,<1",
|
||||
"python-multipart>=0.0.27,<1",
|
||||
"gitpython>=3.1.50,<4",
|
||||
"langsmith>=0.8.0,<1",
|
||||
"langsmith>=0.8.18,<1",
|
||||
"authlib>=1.6.12",
|
||||
"pip>=26.1.2",
|
||||
"aiohttp>=3.14.0",
|
||||
# [chunking] carried here because override-dependencies replace the whole
|
||||
# requirement; without it the docling extra's chunking deps get stripped.
|
||||
"docling-core[chunking]>=2.74.1",
|
||||
"pydantic-settings>=2.14.0",
|
||||
"paramiko>=5.0.0",
|
||||
"starlette>=1.3.1",
|
||||
"msgpack>=1.2.1",
|
||||
"pydantic-settings>=2.14.2",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
112
uv.lock
generated
112
uv.lock
generated
@@ -17,7 +17,10 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
msgpack = "2026-06-20T00:00:00Z"
|
||||
langsmith = "2026-06-20T00:00:00Z"
|
||||
pypdf = "2026-06-18T00:00:00Z"
|
||||
pydantic-settings = "2026-06-20T00:00:00Z"
|
||||
|
||||
[manifest]
|
||||
members = [
|
||||
@@ -36,13 +39,14 @@ overrides = [
|
||||
{ name = "gitpython", specifier = ">=3.1.50,<4" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
|
||||
{ name = "langsmith", specifier = ">=0.8.0,<1" },
|
||||
{ name = "langsmith", specifier = ">=0.8.18,<1" },
|
||||
{ name = "msgpack", specifier = ">=1.2.1" },
|
||||
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
|
||||
{ name = "openai", specifier = ">=2.30.0,<3" },
|
||||
{ name = "paramiko", specifier = ">=5.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pip", specifier = ">=26.1.2" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
||||
{ name = "pypdf", specifier = ">=6.13.3,<7" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
@@ -77,7 +81,7 @@ dev = [
|
||||
{ name = "types-redis", specifier = "~=4.6" },
|
||||
{ name = "types-regex", specifier = "==2026.1.15.*" },
|
||||
{ name = "types-requests", specifier = "~=2.31.0.6" },
|
||||
{ name = "vcrpy", specifier = "==7.0.0" },
|
||||
{ name = "vcrpy", specifier = "==8.2.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3946,7 +3950,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.11"
|
||||
version = "0.8.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -3960,9 +3964,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/082410ece26ff9f3ed4f87b014a8675be47cbd7d65f06b922045dfc21c47/langsmith-0.8.11.tar.gz", hash = "sha256:d9b3496f8f7ca63f4f2d1dfd368afd6c527923fff2ce4026c82ce85f37db3965", size = 4495842, upload-time = "2026-06-08T22:54:44.395Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/65/f9c9dc19b21a9076286fafdb0ab732c9019ddf71aa7e7d720a830a98fe2a/langsmith-0.8.11-py3-none-any.whl", hash = "sha256:08aa5e84b00703ecc11dbeafda78d84b92da4e8c6114e0be9b59df9e71afc59b", size = 478985, upload-time = "2026-06-08T22:54:42.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4702,45 +4706,53 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "msgpack"
|
||||
version = "1.1.2"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6989,16 +7001,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.1"
|
||||
version = "2.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9698,17 +9710,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "vcrpy"
|
||||
version = "7.0.0"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "wrapt" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/d3/856e06184d4572aada1dd559ddec3bedc46df1f2edc5ab2c91121a2cccdb/vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50", size = 85502, upload-time = "2024-12-31T00:07:57.894Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5d/1f15b252890c968d42b348d1e9b0aa12d5bf3e776704178ec37cceccdb63/vcrpy-7.0.0-py2.py3-none-any.whl", hash = "sha256:55791e26c18daa363435054d8b35bd41a4ac441b6676167635d1b37a71dbe124", size = 42321, upload-time = "2024-12-31T00:07:55.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user