diff --git a/lib/devtools/pyproject.toml b/lib/devtools/pyproject.toml index 815c8392f..7eebc9ea4 100644 --- a/lib/devtools/pyproject.toml +++ b/lib/devtools/pyproject.toml @@ -11,7 +11,7 @@ classifiers = ["Private :: Do Not Upload"] private = true dependencies = [ "click~=8.1.7", - "toml~=0.10.2", + "tomlkit~=0.13.2", "openai>=1.83.0,<3", "python-dotenv~=1.1.1", "pygithub~=1.59.1", @@ -25,6 +25,10 @@ release = "crewai_devtools.cli:release" docs-check = "crewai_devtools.docs_check:docs_check" devtools = "crewai_devtools.cli:main" +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--noconftest" + [tool.uv] exclude-newer = "3 days" diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index 9f7b469be..785f943c7 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -1,8 +1,8 @@ """Development tools for version bumping and git automation.""" +from collections.abc import Mapping import os from pathlib import Path -import re import subprocess import sys import tempfile @@ -18,6 +18,7 @@ from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel from rich.prompt import Confirm +import tomlkit from crewai_devtools.docs_check import docs_check from crewai_devtools.prompts import RELEASE_NOTES_PROMPT, TRANSLATE_RELEASE_NOTES_PROMPT @@ -169,18 +170,17 @@ def update_pyproject_version(file_path: Path, new_version: str) -> bool: if not file_path.exists(): return False - content = file_path.read_text() - new_content = re.sub( - r'^(version\s*=\s*")[^"]+(")', - rf"\g<1>{new_version}\2", - content, - count=1, - flags=re.MULTILINE, - ) - if new_content != content: - file_path.write_text(new_content) - return True - return False + doc = tomlkit.parse(file_path.read_text()) + project = doc.get("project") + if project is None: + return False + old_version = project.get("version") + if old_version is None or old_version == new_version: + return False + + project["version"] = new_version + file_path.write_text(tomlkit.dumps(doc)) + return True _DEFAULT_WORKSPACE_PACKAGES: Final[list[str]] = [ @@ -473,6 +473,14 @@ def update_changelog( return True +def _is_crewai_dep(spec: str) -> bool: + """Return True if *spec* is a ``crewai`` or ``crewai[...]`` dependency.""" + if not spec.startswith("crewai"): + return False + rest = spec[6:] # after "crewai" + return len(rest) > 0 and rest[0] in ("[", "=", ">", "<", "~", "!") + + def _pin_crewai_deps(content: str, version: str) -> str: """Replace crewai dependency version pins in a pyproject.toml string. @@ -486,11 +494,21 @@ def _pin_crewai_deps(content: str, version: str) -> str: Returns: Transformed content. """ - return re.sub( - r'"crewai(\[tools\])?(==|>=)[^"]*"', - lambda m: f'"crewai{(m.group(1) or "")!s}=={version}"', - content, - ) + doc = tomlkit.parse(content) + for key in ("dependencies", "optional-dependencies"): + deps = doc.get("project", {}).get(key) + if deps is None: + continue + # optional-dependencies is a table of lists; dependencies is a list + dep_lists = deps.values() if isinstance(deps, Mapping) else [deps] + for dep_list in dep_lists: + for i, dep in enumerate(dep_list): + s = str(dep) + if not _is_crewai_dep(s) or ("==" not in s and ">=" not in s): + continue + extras = s[6 : s.index("]") + 1] if "[" in s[6:7] else "" + dep_list[i] = f"crewai{extras}=={version}" + return tomlkit.dumps(doc) def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]: @@ -1049,6 +1067,11 @@ _ENTERPRISE_EXTRA_PACKAGES: Final[tuple[str, ...]] = tuple( for p in os.getenv("ENTERPRISE_EXTRA_PACKAGES", "").split(",") if p.strip() ) +_ENTERPRISE_WORKFLOW_PATHS: Final[tuple[str, ...]] = tuple( + p.strip() + for p in os.getenv("ENTERPRISE_WORKFLOW_PATHS", "").split(",") + if p.strip() +) def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool: @@ -1072,6 +1095,86 @@ def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool: return False +def _update_enterprise_workflows(repo_dir: Path, version: str) -> list[Path]: + """Update crewai version pins in enterprise CI workflow files. + + Applies ``_repin_crewai_install`` line-by-line on the raw file so + only version numbers change and all formatting is preserved. + + Args: + repo_dir: Root of the cloned enterprise repo. + version: New crewai version string. + + Returns: + List of workflow paths that were modified. + """ + updated: list[Path] = [] + for rel_path in _ENTERPRISE_WORKFLOW_PATHS: + workflow = repo_dir / rel_path + if not workflow.exists(): + continue + + raw = workflow.read_text() + lines = raw.splitlines(keepends=True) + changed = False + for i, line in enumerate(lines): + if "crewai[" not in line: + continue + new_line = _repin_crewai_install(line, version) + if new_line != line: + lines[i] = new_line + changed = True + + if changed: + new_raw = "".join(lines) + else: + new_raw = raw + + if new_raw != raw: + workflow.write_text(new_raw) + updated.append(workflow) + + return updated + + +def _repin_crewai_install(run_value: str, version: str) -> str: + """Rewrite ``crewai[extras]==old`` pins in a shell command string. + + Splits on the known ``crewai[`` prefix and reconstructs the pin + with the new version, avoiding regex. + + Args: + run_value: The ``run:`` string from a workflow step. + version: New version to pin to. + + Returns: + The updated string. + """ + result: list[str] = [] + remainder = run_value + marker = "crewai[" + while marker in remainder: + before, _, after = remainder.partition(marker) + result.append(before) + # after looks like: a2a]==1.14.0" ... + bracket_end = after.index("]") + extras = after[:bracket_end] + rest = after[bracket_end + 1 :] + if rest.startswith("=="): + # Find end of version — next quote or whitespace + ver_start = 2 # len("==") + ver_end = ver_start + while ver_end < len(rest) and rest[ver_end] not in ('"', "'", " ", "\n"): + ver_end += 1 + result.append(f"crewai[{extras}]=={version}") + remainder = rest[ver_end:] + else: + result.append(f"crewai[{extras}]") + remainder = rest + result.append(remainder) + return "".join(result) + + _DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test" _PYPI_POLL_INTERVAL: Final[int] = 15 @@ -1099,11 +1202,7 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None: pyproject = repo_dir / "pyproject.toml" content = pyproject.read_text() - new_content = re.sub( - r'"crewai\[tools\]==[^"]+"', - f'"crewai[tools]=={version}"', - content, - ) + new_content = _pin_crewai_deps(content, version) if new_content == content: console.print( "[yellow]Warning:[/yellow] No crewai[tools] pin found to update" @@ -1262,6 +1361,12 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non f"[green]✓[/green] Updated crewai[tools] dep in {enterprise_dep_path}" ) + # --- update crewai pins in CI workflows --- + for wf in _update_enterprise_workflows(repo_dir, version): + console.print( + f"[green]✓[/green] Updated crewai pin in {wf.relative_to(repo_dir)}" + ) + _wait_for_pypi("crewai", version) console.print("\nSyncing workspace...") diff --git a/lib/devtools/tests/__init__.py b/lib/devtools/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py new file mode 100644 index 000000000..eb93dd235 --- /dev/null +++ b/lib/devtools/tests/test_toml_updates.py @@ -0,0 +1,225 @@ +"""Tests for TOML-based version and dependency update functions.""" + +from pathlib import Path +from textwrap import dedent + +from crewai_devtools.cli import ( + _pin_crewai_deps, + _repin_crewai_install, + update_pyproject_version, +) + + +# --- update_pyproject_version --- + + +class TestUpdatePyprojectVersion: + def test_updates_version(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + dedent("""\ + [project] + name = "my-pkg" + version = "1.0.0" + """) + ) + + assert update_pyproject_version(pyproject, "2.0.0") is True + assert 'version = "2.0.0"' in pyproject.read_text() + + def test_returns_false_when_already_current(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + dedent("""\ + [project] + name = "my-pkg" + version = "1.0.0" + """) + ) + + assert update_pyproject_version(pyproject, "1.0.0") is False + + def test_returns_false_when_no_project_section(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.ruff]\nline-length = 88\n") + + assert update_pyproject_version(pyproject, "1.0.0") is False + + def test_returns_false_when_version_is_dynamic(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + dedent("""\ + [project] + name = "my-pkg" + dynamic = ["version"] + """) + ) + + assert update_pyproject_version(pyproject, "1.0.0") is False + assert 'version = "1.0.0"' not in pyproject.read_text() + + def test_returns_false_for_missing_file(self, tmp_path: Path) -> None: + assert update_pyproject_version(tmp_path / "nope.toml", "1.0.0") is False + + def test_preserves_comments_and_formatting(self, tmp_path: Path) -> None: + content = dedent("""\ + # This is important + [project] + name = "my-pkg" + version = "1.0.0" # current version + description = "A package" + """) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(content) + + update_pyproject_version(pyproject, "2.0.0") + result = pyproject.read_text() + + assert "# This is important" in result + assert 'description = "A package"' in result + + +# --- _pin_crewai_deps --- + + +class TestPinCrewaiDeps: + def test_pins_exact_version(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai==1.0.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai==2.0.0"' in result + + def test_pins_minimum_version(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai>=1.0.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai==2.0.0"' in result + assert ">=" not in result + + def test_pins_with_tools_extra(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai[tools]==1.0.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai[tools]==2.0.0"' in result + + def test_leaves_unrelated_deps_alone(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "requests>=2.0", + "crewai==1.0.0", + "click~=8.1", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"requests>=2.0"' in result + assert '"click~=8.1"' in result + + def test_handles_optional_dependencies(self) -> None: + content = dedent("""\ + [project] + dependencies = [] + + [project.optional-dependencies] + tools = [ + "crewai[tools]>=1.0.0", + ] + """) + result = _pin_crewai_deps(content, "3.0.0") + assert '"crewai[tools]==3.0.0"' in result + + def test_handles_multiple_crewai_entries(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai==1.0.0", + "crewai[tools]==1.0.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai==2.0.0"' in result + assert '"crewai[tools]==2.0.0"' in result + + def test_preserves_arbitrary_extras(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai[a2a]==1.0.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai[a2a]==2.0.0"' in result + + def test_no_deps_returns_unchanged(self) -> None: + content = dedent("""\ + [project] + name = "empty" + """) + result = _pin_crewai_deps(content, "2.0.0") + assert "empty" in result + + def test_skips_crewai_without_version_specifier(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai-tools~=1.0", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai-tools~=1.0"' in result + + def test_skips_crewai_extras_without_pin(self) -> None: + content = dedent("""\ + [project] + dependencies = [ + "crewai[tools]", + ] + """) + result = _pin_crewai_deps(content, "2.0.0") + assert '"crewai[tools]"' in result + assert "==" not in result + + +# --- _repin_crewai_install --- + + +class TestRepinCrewaiInstall: + def test_repins_a2a_extra(self) -> None: + result = _repin_crewai_install('uv pip install "crewai[a2a]==1.14.0"', "2.0.0") + assert result == 'uv pip install "crewai[a2a]==2.0.0"' + + def test_repins_tools_extra(self) -> None: + result = _repin_crewai_install('uv pip install "crewai[tools]==1.0.0"', "3.0.0") + assert result == 'uv pip install "crewai[tools]==3.0.0"' + + def test_leaves_unrelated_commands_alone(self) -> None: + cmd = "uv pip install requests" + assert _repin_crewai_install(cmd, "2.0.0") == cmd + + def test_handles_multiple_pins(self) -> None: + cmd = 'pip install "crewai[a2a]==1.0.0" "crewai[tools]==1.0.0"' + result = _repin_crewai_install(cmd, "2.0.0") + assert result == 'pip install "crewai[a2a]==2.0.0" "crewai[tools]==2.0.0"' + + def test_preserves_surrounding_text(self) -> None: + cmd = 'echo hello && uv pip install "crewai[a2a]==1.14.0" && echo done' + result = _repin_crewai_install(cmd, "2.0.0") + assert ( + result == 'echo hello && uv pip install "crewai[a2a]==2.0.0" && echo done' + ) + + def test_no_version_specifier_unchanged(self) -> None: + cmd = 'pip install "crewai[tools]>=1.0"' + assert _repin_crewai_install(cmd, "2.0.0") == cmd diff --git a/pyproject.toml b/pyproject.toml index 6f6404677..5894a9b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ ignore-decorators = ["typing.overload"] "lib/crewai/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements, unnecessary assignments, and hardcoded passwords in tests "lib/crewai-tools/tests/**/*.py" = ["S101", "RET504", "S105", "S106", "RUF012", "N818", "E402", "RUF043", "S110", "B017"] # Allow various test-specific patterns "lib/crewai-files/tests/**/*.py" = ["S101", "RET504", "S105", "S106", "B017", "F841"] # Allow assert statements and blind exception assertions in tests +"lib/devtools/tests/**/*.py" = ["S101"] [tool.mypy] diff --git a/uv.lock b/uv.lock index d52f6621f..5f637e156 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-05T10:53:01.907268Z" +exclude-newer = "2026-04-05T11:09:48.9111Z" exclude-newer-span = "P3D" [manifest] @@ -1358,7 +1358,7 @@ dependencies = [ { name = "pygithub" }, { name = "python-dotenv" }, { name = "rich" }, - { name = "toml" }, + { name = "tomlkit" }, ] [package.metadata] @@ -1368,7 +1368,7 @@ requires-dist = [ { name = "pygithub", specifier = "~=1.59.1" }, { name = "python-dotenv", specifier = "~=1.1.1" }, { name = "rich", specifier = ">=13.9.4" }, - { name = "toml", specifier = "~=0.10.2" }, + { name = "tomlkit", specifier = "~=0.13.2" }, ] [[package]] @@ -8037,15 +8037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.0.2" @@ -8066,11 +8057,11 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.14.0" +version = "0.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] [[package]]