Compare commits

...
23 Commits
Author SHA1 Message Date
MervinPraison ea270f747a Release v0.2.35 2026-07-31 02:37:35 +00:00
praisonai-triage-agent[bot] d06254fc68 Merge pull request #66 from MervinPraison/claude/issue-57-20260727-0902
fix(video): align motion graphics team tests with hierarchical API
2026-07-27 14:58:54 +00:00
MervinPraisonandMervin Praison e0b164c530 fix(video): use hierarchical coordinator in motion graphics example
Replace stale team.leader.name access with team.agents[0].name to match
the hierarchical AgentTeam API (no static leader in this mode).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 14:14:49 +00:00
MervinPraisonandMervin Praison a8627e295e fix(video): align motion graphics team tests with hierarchical API (fixes #57)
The integration tests asserted `team.leader` and an exact `kwargs` dict,
but the preset uses `process="hierarchical"` where the coordinator is the
first agent (`team.agents[0]`) and `process`/`manager_llm` are forwarded.
Update tests to the hierarchical semantics and document the pattern in the
`motion_graphics_team` docstring.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 14:14:49 +00:00
praisonai-triage-agent[bot] 2b159d8816 Merge pull request #70 from MervinPraison/claude/issue-62-20260727-0908
fix: align motion graphics team tests with hierarchical AgentTeam API
2026-07-27 14:12:05 +00:00
praisonai-triage-agent[bot] 27b0f5e7a3 Merge pull request #65 from MervinPraison/claude/issue-54-20260727-0859
fix: add video-motion deps to dev extra; skip video tests when missing
2026-07-27 14:06:18 +00:00
MervinPraisonandMervin Praison ea9407b34b style(video-tests): fix ruff I001/SIM117 in video test files
Clears this PR's lint contribution (video dir 13 -> 0 ruff errors):
sort imports and combine nested `with` statements. No behaviour change;
video tests still pass (54 passed with deps). Repo-wide ruff debt in
praisonai_tools/ (~5321 pre-existing errors) is out of scope for this
video-test-deps PR.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:57 +00:00
MervinPraisonandMervin Praison ae16e3eebb test(video): narrow video-dep guards to backend-constructing tests
Replace the directory-wide pytest.importorskip in tests/unit/video/conftest.py
with per-test skipif guards on only the tests that build a real
HtmlRenderBackend. Dependency-independent protocol, mock-backend and retry
tests now run on a minimal `pip install -e '.[dev]'` (40 passed / 14 skipped
without video-motion; 54 passed with it), addressing Greptile's feedback that
the broad skip dropped useful coverage.

Also make test_import_error_handling deterministic by patching async_playwright
so the imageio-ffmpeg guard is what's under test regardless of installed deps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:57 +00:00
MervinPraisonandMervin Praison 803f2b219e fix: add video-motion deps to dev extra and skip video tests when missing (fixes #54)
- Add playwright and imageio-ffmpeg to the [dev] extra so the full pytest
  suite runs after `pip install -e ".[dev]"` (Option A).
- Add tests/unit/video/conftest.py with pytest.importorskip guards so
  minimal installs skip video tests gracefully instead of erroring (Option C).
- Document the video-motion render deps and `playwright install chromium`
  step in README Testing section.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:42 +00:00
MervinPraisonandMervin Praison 7b36689195 fix(ci): pin ruff to E/F rule families to unblock CI lint
The CI `ruff check praisonai_tools tests` step had no ruff config, so a
newer ruff release pulled in strict opinionated defaults (UP/I/W/SIM/RUF)
flagging 5000+ pre-existing, non-functional findings across untouched
files. This pre-dated PR #62 (failing on main at b96bfbc).

Add [tool.ruff.lint] selecting the historical E/F baseline that the
existing `--ignore E…,F…` CI flags already imply, restoring green
without editing unrelated files or weakening any tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:02:27 +00:00
MervinPraisonandMervin Praison f519a92927 fix(tests): align motion graphics team tests with hierarchical API (fixes #62)
Update integration tests to match the hierarchical process pattern used by
motion_graphics_team(): the coordinator is the semantic leader at agents[0]
and process/manager_llm are forwarded into AgentTeam kwargs. Document the
hierarchical coordinator role in the team docstring.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:02:06 +00:00
MervinPraison a79978d3b7 Release v0.2.34 2026-07-27 12:01:13 +00:00
praisonai-triage-agent[bot] fb45d41170 Merge pull request #69 from MervinPraison/claude/issue-59-20260727-0906
fix: include video-motion deps in dev extra + graceful skip
2026-07-27 12:00:18 +00:00
MervinPraisonandMervin Praison 191a4a6641 fix(ci): pin ruff rule set to historical default to stop spurious lint failures
The CI "Lint with ruff" step installs ruff unpinned; ruff 0.16.0 enabled
thousands of new default rules across the pre-existing codebase, failing
main and every PR (including this one) regardless of their diff. Pin
[tool.ruff.lint] select to E4/E7/E9/F — the rules the CI --ignore flags
were written against — restoring deterministic, intent-preserving linting
without mass-rewriting existing code.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:36:58 +00:00
MervinPraisonandMervin Praison 3060daa92c test(video): scope video-dep skips to backend-bound tests (addresses Greptile P2)
Replace the directory-wide pytest.importorskip guard in the video conftest
with a `requires_video_deps` marker + collection hook that skips ONLY the
tests that construct HtmlRenderBackend (which hard-requires playwright /
imageio-ffmpeg). Dependency-independent tests (protocol definitions,
dataclasses, mocked backends) now retain coverage on minimal installs.

Verified:
- deps absent:  39 passed, 15 skipped (previously entire tree skipped)
- deps present: 54 passed (unchanged)
- full unit:    227 passed, 6 skipped

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:29:43 +00:00
MervinPraisonandMervin Praison bcaa5a5219 fix: include video-motion deps in dev extra and skip video tests when absent (fixes #59)
- Add playwright and imageio-ffmpeg to the [dev] extra so a standard
  `pip install -e ".[dev]"` runs the video/motion-graphics tests green.
- Add tests/unit/video/conftest.py with importorskip guards so minimal
  installs skip the video suite gracefully instead of raising ImportError.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:08:46 +00:00
MervinPraison b96bfbc7e2 fix(release): 3-day patch cadence and explicit PyPI __token__ auth
Extend release-gate dedupe from daily to every 3 days and configure uv publish with UV_PUBLISH_USERNAME=__token__ per PyPI API token guidance.
2026-07-25 18:24:01 +01:00
MervinPraison f146c6c7ad Release v0.2.33 2026-07-10 04:01:00 +00:00
Mervin Praison 82fc2031f4 Merge pull request #41 from rakshith48/firecrawl-v2-upgrade
Upgrade Firecrawl tool to firecrawl-py v2 SDK + add search()
2026-07-09 18:35:36 +01:00
Rakshith RamprakashandClaude Opus 4.8 ca809f7f4d fix(firecrawl): coerce crawl/search limit to int (LLM string inputs)
LLMs/agents pass `limit` as a string ("10") or None; without coercion
the `data[:limit]` / `web[:limit]` slices raise TypeError. Coerce with a
try/except fallback to the method default. Addresses gemini-code-assist
review on PR #41.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 12:48:05 +05:30
Rakshith RamprakashandClaude Opus 4.8 a5d92e8cb4 feat(firecrawl): register firecrawl_search helper
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:49:31 +05:30
Rakshith RamprakashandClaude Opus 4.8 ca4267be06 feat(firecrawl): add search() to the tool + firecrawl_search helper (v2 /search)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:49:28 +05:30
rramprakashandClaude Opus 4.8 4b03908c11 Upgrade Firecrawl tool to firecrawl-py v2 SDK (>=4)
Migrate FirecrawlTool from the legacy v1 SDK to the v2 SDK while
preserving the tool's public surface and return shapes.

- Import `Firecrawl` instead of `FirecrawlApp`; client is `Firecrawl(api_key=...)`.
- scrape: `client.scrape(url, formats=[...])` (kwargs, not a params dict).
  v2 returns a typed `Document`, so read `.markdown` via attribute access
  and coerce the typed `DocumentMetadata` to a plain dict via `model_dump`.
- crawl: `client.crawl(url, limit=..., poll_interval=...)` returns a typed
  `CrawlJob`; iterate `.data` (list of `Document`) and read
  `.metadata.source_url` / `.metadata.title` via attribute access.
- run() dispatcher and the module-level `firecrawl_scrape` helper are
  unchanged; scrape() still returns a dict and crawl() still returns a
  list[dict] so agents relying on these shapes keep working.

No pin change: firecrawl-py is not pinned in pyproject.toml (runtime-only
optional install); install guidance updated to `firecrawl-py>=4`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:41:46 +05:30
17 changed files with 331 additions and 70 deletions

No files matched your search

+50 -1
View File
@@ -29,4 +29,53 @@ try {
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
process.exit(failed ? 1 : 0);
(async () => {
const noonUtc = new Date('2026-07-08T12:00:00Z');
const mockGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert('PATCH_RELEASE_INTERVAL_DAYS is 3', rg.PATCH_RELEASE_INTERVAL_DAYS === 3);
assert(
'hasSuccessfulReleaseWithinDays blocks recent release',
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc)
);
const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert(
'hasSuccessfulReleaseWithinDays allows after 3 days',
!(await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc))
);
process.exit(failed ? 1 : 0);
})();
+28 -6
View File
@@ -6,6 +6,8 @@ const https = require('https');
const config = require('./gate-config.js');
const PACKAGE_PATHS = config.packagePaths;
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
@@ -61,8 +63,18 @@ function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
@@ -71,10 +83,15 @@ async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date())
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCiSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -116,9 +133,11 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
return out;
}
@@ -196,6 +215,9 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
releaseIntervalStart,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+3 -2
View File
@@ -2,11 +2,12 @@ name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: CI success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
# Backstop: cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 0 */3 * *'
workflow_dispatch:
inputs:
dry_run:
+3 -1
View File
@@ -133,7 +133,9 @@ jobs:
- name: Publish to PyPI
if: inputs.dry_run != true
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
run: |
uv publish
+13
View File
@@ -703,6 +703,19 @@ pip install praisonai-tools[dev]
pytest tests/ -v
```
The `dev` extra installs the video/motion-graphics render deps
(`playwright`, `imageio-ffmpeg`) so the full suite runs. To exercise the
HTML render backend you also need the Chromium browser:
```bash
pip install -e ".[dev]"
playwright install chromium
pytest tests/ -v
```
If you use a minimal install without those deps, the video tests under
`tests/unit/video/` are skipped automatically rather than failing.
---
## License
+1 -1
View File
@@ -194,7 +194,7 @@ def example_team_preset():
print("✓ Motion graphics team created successfully!")
print(f" Agents: {[agent.name for agent in team.agents]}")
print(f" Leader: {team.leader.name}")
print(f" Coordinator: {team.agents[0].name}")
print(f" Workspace: {team._motion_graphics_workspace}")
except ImportError as e:
+2
View File
@@ -122,6 +122,7 @@ def __getattr__(name):
# Firecrawl
"FirecrawlTool": "firecrawl_tool",
"firecrawl_scrape": "firecrawl_tool",
"firecrawl_search": "firecrawl_tool",
# Serper
"SerperTool": "serper_tool",
"serper_search": "serper_tool",
@@ -685,6 +686,7 @@ __all__ = [
# Firecrawl Tool
"FirecrawlTool",
"firecrawl_scrape",
"firecrawl_search",
# Serper Tool
"SerperTool",
"serper_search",
+86 -18
View File
@@ -25,7 +25,7 @@ class FirecrawlTool(BaseTool):
"""Tool for web scraping using Firecrawl."""
name = "firecrawl"
description = "Scrape and crawl websites using Firecrawl."
description = "Scrape, crawl, and search the web using Firecrawl."
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FIRECRAWL_API_KEY")
@@ -38,10 +38,12 @@ class FirecrawlTool(BaseTool):
if not self.api_key:
raise ValueError("FIRECRAWL_API_KEY is required")
try:
from firecrawl import FirecrawlApp
from firecrawl import Firecrawl
except ImportError:
raise ImportError("firecrawl-py not installed. Install with: pip install firecrawl-py")
self._client = FirecrawlApp(api_key=self.api_key)
raise ImportError(
"firecrawl-py>=4 not installed. Install with: pip install 'firecrawl-py>=4'"
)
self._client = Firecrawl(api_key=self.api_key)
return self._client
def run(
@@ -56,6 +58,8 @@ class FirecrawlTool(BaseTool):
return self.scrape(url=url, **kwargs)
elif action == "crawl":
return self.crawl(url=url, **kwargs)
elif action == "search":
return self.search(query=kwargs.get("query") or url, limit=kwargs.get("limit", 5))
else:
return {"error": f"Unknown action: {action}"}
@@ -68,16 +72,28 @@ class FirecrawlTool(BaseTool):
return {"error": "FIRECRAWL_API_KEY not configured"}
try:
params = {}
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
# typed Document, not a dict.
kwargs: Dict[str, Any] = {}
if formats:
params["formats"] = formats
result = self.client.scrape_url(url, params=params if params else None)
kwargs["formats"] = formats
result = self.client.scrape(url, **kwargs)
# v2 returns a Document object; metadata is a typed DocumentMetadata.
# Coerce to a plain dict to keep this tool's return shape stable.
metadata = getattr(result, "metadata", None)
if metadata is not None and hasattr(metadata, "model_dump"):
metadata = metadata.model_dump(exclude_none=True)
elif metadata is None:
metadata = {}
markdown = getattr(result, "markdown", None) or ""
return {
"url": url,
"markdown": result.get("markdown", "")[:5000],
"metadata": result.get("metadata", {}),
"markdown": markdown[:5000],
"metadata": metadata,
}
except Exception as e:
logger.error(f"Firecrawl scrape error: {e}")
@@ -92,25 +108,77 @@ class FirecrawlTool(BaseTool):
return [{"error": "FIRECRAWL_API_KEY not configured"}]
try:
result = self.client.crawl_url(
# `limit` may arrive as a string (e.g. "10") or None when invoked by
# an LLM/agent; coerce to int so the SDK call and `data[:limit]` slice
# below don't raise TypeError.
try:
limit = int(limit) if limit is not None else 10
except (ValueError, TypeError):
limit = 10
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
# typed CrawlJob whose `.data` is a list of Document objects.
result = self.client.crawl(
url,
params={"limit": limit},
limit=limit,
poll_interval=5,
)
data = getattr(result, "data", None) or []
pages = []
for page in result.get("data", [])[:limit]:
for page in data[:limit]:
metadata = getattr(page, "metadata", None)
pages.append({
"url": page.get("metadata", {}).get("sourceURL"),
"title": page.get("metadata", {}).get("title"),
"markdown": page.get("markdown", "")[:2000],
"url": getattr(metadata, "source_url", None),
"title": getattr(metadata, "title", None),
"markdown": (getattr(page, "markdown", None) or "")[:2000],
})
return pages
except Exception as e:
logger.error(f"Firecrawl crawl error: {e}")
return [{"error": str(e)}]
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""Search the web and return results."""
if not query:
return [{"error": "query is required"}]
if not self.api_key:
return [{"error": "FIRECRAWL_API_KEY not configured"}]
try:
# `limit` may arrive as a string or None from an LLM/agent; coerce to
# int so the SDK call and `web[:limit]` slice below don't raise.
try:
limit = int(limit) if limit is not None else 5
except (ValueError, TypeError):
limit = 5
# firecrawl-py v2 returns a typed SearchData object whose results are
# grouped by source; read the web results and coerce to plain dicts.
result = self.client.search(query, limit=limit)
web = getattr(result, "web", None) or []
results = []
for item in web[:limit]:
results.append({
"url": getattr(item, "url", None),
"title": getattr(item, "title", None),
"description": getattr(item, "description", None),
"markdown": (getattr(item, "markdown", None) or "")[:2000],
})
return results
except Exception as e:
logger.error(f"Firecrawl search error: {e}")
return [{"error": str(e)}]
def firecrawl_scrape(url: str) -> Dict[str, Any]:
"""Scrape URL with Firecrawl."""
return FirecrawlTool().scrape(url=url)
def firecrawl_search(query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""Search the web with Firecrawl."""
return FirecrawlTool().search(query=query, limit=limit)
@@ -35,6 +35,11 @@ def motion_graphics_team(
- Researcher: Optional web search for content research
- CodeExplorer: Optional git repository exploration
- Animator: HTML/GSAP composition authoring and rendering
The team runs with ``process="hierarchical"``: the ``manager_llm``
orchestrates the worker agents at runtime rather than exposing a static
``leader`` reference. The coordinator is the semantic leader and is always
the first agent in ``team.agents``.
Args:
research: Include research specialist
@@ -46,6 +51,12 @@ def motion_graphics_team(
Returns:
AgentTeam configured for motion graphics creation
Note:
The team uses ``process="hierarchical"`` where the ``manager_llm``
orchestrates the worker agents at runtime. The coordinator is the
semantic leader and is always the first agent (``team.agents[0]``);
there is no static ``team.leader`` reference in this mode.
"""
if AgentTeam is None:
raise ImportError(
+10 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.32"
version = "0.2.35"
description = "Extended tools for PraisonAI Agents"
authors = [
{name = "Mervin Praison"}
@@ -32,6 +32,8 @@ praisonai-tools = "praisonai_tools.cli:main"
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"playwright>=1.40",
"imageio-ffmpeg>=0.5",
]
wordpress = [
"praisonaiwp[ai]>=0.1.0",
@@ -83,4 +85,10 @@ packages = ["praisonai_tools"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_mode = "auto"
[tool.ruff.lint]
# Pin the rule set to the historical default (Pyflakes + a subset of
# pycodestyle) so an unpinned ruff upgrade does not spuriously fail CI by
# enabling thousands of new default rules across the pre-existing codebase.
select = ["E4", "E7", "E9", "F"]
+14 -6
View File
@@ -164,8 +164,11 @@ class TestMotionGraphicsTeam:
custom_param="test"
)
# Check that custom parameters are passed through
assert team.kwargs == {"custom_param": "test"}
# Hierarchical teams forward process + manager_llm alongside
# any user-supplied kwargs, so assert individual entries.
assert team.kwargs["custom_param"] == "test"
assert team.kwargs["process"] == "hierarchical"
assert team.kwargs["manager_llm"] == "gpt-4"
# Check that workspace is set
assert team._motion_graphics_workspace == Path(tmpdir)
@@ -209,15 +212,20 @@ class TestMotionGraphicsTeam:
@patch('praisonai_tools.video.motion_graphics.team.search_web', MockSearch())
@patch('praisonai_tools.video.motion_graphics.team.create_motion_graphics_agent')
def test_coordinator_is_leader(self, mock_create_agent):
"""Test that coordinator is set as team leader."""
"""Test that coordinator is the semantic leader (first agent).
In hierarchical process mode the manager LLM orchestrates workers,
so the coordinator is the first agent rather than a static
``team.leader`` reference.
"""
mock_animator = MockAgent(name="animator")
mock_create_agent.return_value = mock_animator
with tempfile.TemporaryDirectory() as tmpdir:
team = motion_graphics_team(workspace=tmpdir)
assert team.leader is not None
assert team.leader.name == "coordinator"
coordinator = team.agents[0]
assert coordinator.name == "coordinator"
@patch('praisonai_tools.video.motion_graphics.team.AgentTeam', MockAgentTeam)
@patch('praisonai_tools.video.motion_graphics.team.Agent', MockAgent)
@@ -278,7 +286,7 @@ class TestMotionGraphicsTeam:
with tempfile.TemporaryDirectory() as tmpdir:
team = motion_graphics_team(workspace=tmpdir)
coordinator = team.leader
coordinator = team.agents[0]
instructions = coordinator.instructions.lower()
assert "coordinator" in instructions
+45
View File
@@ -0,0 +1,45 @@
"""Pytest configuration for video/motion-graphics tests.
Most tests in this tree are fully mocked (protocols, dataclasses, mocked
backends) and run without the optional ``video-motion`` dependencies. Only a
subset actually constructs :class:`HtmlRenderBackend`, which hard-requires
``playwright`` and ``imageio-ffmpeg`` at construction time.
Rather than skipping the whole tree when those packages are absent (for example
a minimal ``pip install -e ".[dev]"`` where the wheels could not be resolved),
gate *only* the dependency-bound tests. Mark them with
``@pytest.mark.requires_video_deps`` and this hook skips just those when the
extras are missing, preserving coverage for the dependency-independent tests.
"""
import importlib.util
import pytest
_VIDEO_DEPS = ("playwright", "imageio_ffmpeg")
_MISSING_VIDEO_DEPS = [
name for name in _VIDEO_DEPS if importlib.util.find_spec(name) is None
]
VIDEO_DEPS_AVAILABLE = not _MISSING_VIDEO_DEPS
def pytest_configure(config):
config.addinivalue_line(
"markers",
"requires_video_deps: mark test as requiring the optional "
"video-motion extras (playwright, imageio-ffmpeg).",
)
def pytest_collection_modifyitems(config, items):
if VIDEO_DEPS_AVAILABLE:
return
skip_reason = (
"video-motion extra not installed "
"(pip install -e '.[dev,video-motion]'); missing: "
+ ", ".join(_MISSING_VIDEO_DEPS)
)
skip_marker = pytest.mark.skip(reason=skip_reason)
for item in items:
if "requires_video_deps" in item.keywords:
item.add_marker(skip_marker)
+1
View File
@@ -13,6 +13,7 @@ except ImportError:
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="Motion graphics backend not available")
@pytest.mark.requires_video_deps
class TestBackendSafety:
"""Test workspace safety features."""
+47 -26
View File
@@ -1,10 +1,11 @@
"""Unit tests for HTML render backend."""
import pytest
import tempfile
import asyncio
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
from unittest.mock import AsyncMock, Mock, patch
import pytest
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
from praisonai_tools.video.motion_graphics.protocols import RenderOpts
@@ -13,22 +14,32 @@ from praisonai_tools.video.motion_graphics.protocols import RenderOpts
class TestHtmlRenderBackend:
"""Test HTML render backend."""
@pytest.mark.requires_video_deps
def test_import_error_handling(self):
"""Test that import errors are handled properly."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None):
with pytest.raises(ImportError, match="Playwright not installed"):
HtmlRenderBackend()
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None):
with pytest.raises(ImportError, match="imageio-ffmpeg not installed"):
HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None),
pytest.raises(ImportError, match="Playwright not installed"),
):
HtmlRenderBackend()
# Ensure the playwright guard passes so the imageio-ffmpeg guard is the
# one under test, regardless of whether the optional deps are installed.
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None),
pytest.raises(ImportError, match="imageio-ffmpeg not installed"),
):
HtmlRenderBackend()
def test_init_success(self):
"""Test successful initialization."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
backend = HtmlRenderBackend()
assert backend is not None
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
backend = HtmlRenderBackend()
assert backend is not None
class TestLinting:
@@ -36,9 +47,11 @@ class TestLinting:
def setup_method(self):
"""Set up test fixtures."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
self.backend = HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
self.backend = HtmlRenderBackend()
@pytest.mark.asyncio
async def test_lint_missing_index_html(self):
@@ -198,9 +211,11 @@ class TestRendering:
def setup_method(self):
"""Set up test fixtures."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
self.backend = HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
self.backend = HtmlRenderBackend()
@pytest.mark.asyncio
async def test_render_missing_index_html(self):
@@ -321,8 +336,10 @@ class TestRendering:
mock_process.returncode = 0
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
# Create fake frame files
frame_dir = Path(tmpdir)
frame_paths = []
@@ -357,8 +374,10 @@ class TestRendering:
mock_process.returncode = 1
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
frame_path = Path(tmpdir) / "frame_000000.png"
frame_path.write_bytes(b"fake")
@@ -378,8 +397,10 @@ class TestRendering:
mock_process.kill = Mock()
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
frame_path = Path(tmpdir) / "frame_000000.png"
frame_path.write_bytes(b"fake")
+11 -3
View File
@@ -6,11 +6,11 @@ from pathlib import Path
from unittest.mock import patch
from praisonai_tools.video.motion_graphics.agent import (
create_motion_graphics_agent,
RenderTools,
_resolve_backend
_resolve_backend,
create_motion_graphics_agent,
)
from praisonai_tools.video.motion_graphics.protocols import RenderResult, LintResult
from praisonai_tools.video.motion_graphics.protocols import LintResult, RenderResult
class MockAgent:
@@ -96,6 +96,7 @@ class TestRenderTools:
class TestResolveBackend:
"""Test backend resolution."""
@pytest.mark.requires_video_deps
def test_resolve_string_backend(self):
"""Test resolving string backend specification."""
backend = _resolve_backend("html")
@@ -119,6 +120,7 @@ class TestResolveBackend:
class TestCreateMotionGraphicsAgent:
"""Test motion graphics agent factory."""
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_defaults(self):
@@ -137,6 +139,7 @@ class TestCreateMotionGraphicsAgent:
"lint_composition", "render_composition"} <= tool_names
assert "motion graphics specialist" in agent.instructions.lower()
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_custom_params(self):
@@ -159,6 +162,7 @@ class TestCreateMotionGraphicsAgent:
with pytest.raises(ImportError, match="praisonaiagents not available"):
create_motion_graphics_agent()
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_auto_workspace(self):
@@ -183,6 +187,7 @@ class TestCreateMotionGraphicsAgent:
assert agent._motion_graphics_backend is mock_backend
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_workspace_creation(self):
@@ -195,6 +200,7 @@ class TestCreateMotionGraphicsAgent:
assert workspace_path.exists()
assert agent._motion_graphics_workspace == workspace_path
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_skill_included(self):
@@ -207,6 +213,7 @@ class TestCreateMotionGraphicsAgent:
assert "timeline" in agent.instructions
assert "window.__timelines" in agent.instructions
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_output_validation(self):
@@ -219,6 +226,7 @@ class TestCreateMotionGraphicsAgent:
assert "Never fabricate file paths" in agent.instructions
assert "3 failed attempts" in agent.instructions
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_agent_tools_configuration(self):
@@ -3,10 +3,10 @@
import pytest
from pathlib import Path
from praisonai_tools.video.motion_graphics.protocols import (
LintResult,
RenderBackendProtocol,
RenderOpts,
LintResult,
RenderResult,
RenderBackendProtocol
)
@@ -112,6 +112,7 @@ class TestRenderResult:
class TestRenderBackendProtocol:
"""Test RenderBackendProtocol protocol."""
@pytest.mark.requires_video_deps
def test_protocol_runtime_checkable(self):
"""Test that protocol is runtime checkable."""
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
+3 -2
View File
@@ -1,12 +1,13 @@
"""Tests for render retry functionality."""
import pytest
import tempfile
from pathlib import Path
import pytest
try:
from praisonai_tools.video.motion_graphics.agent import RenderTools
from praisonai_tools.video.motion_graphics.protocols import RenderResult, RenderOpts
from praisonai_tools.video.motion_graphics.protocols import RenderOpts, RenderResult
AGENT_AVAILABLE = True
except ImportError:
AGENT_AVAILABLE = False