From 24e973d7926ae37586a9830b17853f16fce71342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Tue, 1 Oct 2024 11:33:07 -0700 Subject: [PATCH 1/6] preparing new version --- pyproject.toml | 2 +- src/crewai/cli/templates/crew/pyproject.toml | 2 +- src/crewai/cli/templates/flow/pyproject.toml | 2 +- src/crewai/cli/templates/pipeline/pyproject.toml | 2 +- src/crewai/cli/templates/pipeline_router/pyproject.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 985f57096..5600ccea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "crewai" -version = "0.66.0" +version = "0.67.0" description = "Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks." authors = ["Joao Moura "] readme = "README.md" diff --git a/src/crewai/cli/templates/crew/pyproject.toml b/src/crewai/cli/templates/crew/pyproject.toml index 56944c834..41534fd27 100644 --- a/src/crewai/cli/templates/crew/pyproject.toml +++ b/src/crewai/cli/templates/crew/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/flow/pyproject.toml b/src/crewai/cli/templates/flow/pyproject.toml index 3753cf941..204c8f642 100644 --- a/src/crewai/cli/templates/flow/pyproject.toml +++ b/src/crewai/cli/templates/flow/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } asyncio = "*" [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/pipeline/pyproject.toml b/src/crewai/cli/templates/pipeline/pyproject.toml index e4aecb895..b9a68a643 100644 --- a/src/crewai/cli/templates/pipeline/pyproject.toml +++ b/src/crewai/cli/templates/pipeline/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } asyncio = "*" [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/pipeline_router/pyproject.toml b/src/crewai/cli/templates/pipeline_router/pyproject.toml index 10810ddcb..ca7618b2d 100644 --- a/src/crewai/cli/templates/pipeline_router/pyproject.toml +++ b/src/crewai/cli/templates/pipeline_router/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } [tool.poetry.scripts] From ba55160d6bb096016af70f327b2dea7ee30425f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Tue, 1 Oct 2024 11:37:57 -0700 Subject: [PATCH 2/6] updating dependencies --- poetry.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/poetry.lock b/poetry.lock index 354b48e9f..c2db023dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6103,6 +6103,54 @@ description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ + {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67219632be22f14750f0d1c70e62f204ba69d28f62fd6432ba05ab295853de9b"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4668bd8faf7e5b71c0319407b608f278f279668f358857dbfd10ef1954ac9f90"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb8bea573863762bbf45d1e13f87c2d2fd32cee2dbd50d050f83f87429c9e1ea"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f552023710d4b93d8fb29a91fadf97de89c5926c6bd758897875435f2a939f33"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:016b2e665f778f13d3c438651dd4de244214b527a275e0acf1d44c05bc6026a9"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7befc148de64b6060937231cbff8d01ccf0bfd75aa26383ffdf8d82b12ec04ff"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-win32.whl", hash = "sha256:22b83aed390e3099584b839b93f80a0f4a95ee7f48270c97c90acd40ee646f0b"}, + {file = "SQLAlchemy-2.0.35-cp310-cp310-win_amd64.whl", hash = "sha256:a29762cd3d116585278ffb2e5b8cc311fb095ea278b96feef28d0b423154858e"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e21f66748ab725ade40fa7af8ec8b5019c68ab00b929f6643e1b1af461eddb60"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8a6219108a15fc6d24de499d0d515c7235c617b2540d97116b663dade1a54d62"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042622a5306c23b972192283f4e22372da3b8ddf5f7aac1cc5d9c9b222ab3ff6"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:627dee0c280eea91aed87b20a1f849e9ae2fe719d52cbf847c0e0ea34464b3f7"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fdcd72a789c1c31ed242fd8c1bcd9ea186a98ee8e5408a50e610edfef980d71"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89b64cd8898a3a6f642db4eb7b26d1b28a497d4022eccd7717ca066823e9fb01"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-win32.whl", hash = "sha256:6a93c5a0dfe8d34951e8a6f499a9479ffb9258123551fa007fc708ae2ac2bc5e"}, + {file = "SQLAlchemy-2.0.35-cp311-cp311-win_amd64.whl", hash = "sha256:c68fe3fcde03920c46697585620135b4ecfdfc1ed23e75cc2c2ae9f8502c10b8"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf"}, + {file = "SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f021d334f2ca692523aaf7bbf7592ceff70c8594fad853416a81d66b35e3abf9"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05c3f58cf91683102f2f0265c0db3bd3892e9eedabe059720492dbaa4f922da1"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:032d979ce77a6c2432653322ba4cbeabf5a6837f704d16fa38b5a05d8e21fa00"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2e795c2f7d7249b75bb5f479b432a51b59041580d20599d4e112b5f2046437a3"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:cc32b2990fc34380ec2f6195f33a76b6cdaa9eecf09f0c9404b74fc120aef36f"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-win32.whl", hash = "sha256:9509c4123491d0e63fb5e16199e09f8e262066e58903e84615c301dde8fa2e87"}, + {file = "SQLAlchemy-2.0.35-cp37-cp37m-win_amd64.whl", hash = "sha256:3655af10ebcc0f1e4e06c5900bb33e080d6a1fa4228f502121f28a3b1753cde5"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4c31943b61ed8fdd63dfd12ccc919f2bf95eefca133767db6fbbd15da62078ec"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a62dd5d7cc8626a3634208df458c5fe4f21200d96a74d122c83bc2015b333bc1"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0630774b0977804fba4b6bbea6852ab56c14965a2b0c7fc7282c5f7d90a1ae72"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d625eddf7efeba2abfd9c014a22c0f6b3796e0ffb48f5d5ab106568ef01ff5a"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ada603db10bb865bbe591939de854faf2c60f43c9b763e90f653224138f910d9"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c41411e192f8d3ea39ea70e0fae48762cd11a2244e03751a98bd3c0ca9a4e936"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-win32.whl", hash = "sha256:d299797d75cd747e7797b1b41817111406b8b10a4f88b6e8fe5b5e59598b43b0"}, + {file = "SQLAlchemy-2.0.35-cp38-cp38-win_amd64.whl", hash = "sha256:0375a141e1c0878103eb3d719eb6d5aa444b490c96f3fedab8471c7f6ffe70ee"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ccae5de2a0140d8be6838c331604f91d6fafd0735dbdcee1ac78fc8fbaba76b4"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2a275a806f73e849e1c309ac11108ea1a14cd7058577aba962cd7190e27c9e3c"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732e026240cdd1c1b2e3ac515c7a23820430ed94292ce33806a95869c46bd139"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890da8cd1941fa3dab28c5bac3b9da8502e7e366f895b3b8e500896f12f94d11"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0d8326269dbf944b9201911b0d9f3dc524d64779a07518199a58384c3d37a44"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b76d63495b0508ab9fc23f8152bac63205d2a704cd009a2b0722f4c8e0cba8e0"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-win32.whl", hash = "sha256:69683e02e8a9de37f17985905a5eca18ad651bf592314b4d3d799029797d0eb3"}, + {file = "SQLAlchemy-2.0.35-cp39-cp39-win_amd64.whl", hash = "sha256:aee110e4ef3c528f3abbc3c2018c121e708938adeeff9006428dd7c8555e9b3f"}, + {file = "SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1"}, {file = "sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f"}, ] From 71c5972fc7949229254d04d09ac9db409f844eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Tue, 1 Oct 2024 14:18:11 -0700 Subject: [PATCH 3/6] hadnling pydantic obejct with Optional fields --- src/crewai/utilities/converter.py | 2 ++ src/crewai/utilities/pydantic_schema_parser.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/crewai/utilities/converter.py b/src/crewai/utilities/converter.py index 9a2cde755..4d0af67ca 100644 --- a/src/crewai/utilities/converter.py +++ b/src/crewai/utilities/converter.py @@ -103,10 +103,12 @@ def convert_to_model( return handle_partial_json( result, model, bool(output_json), agent, converter_cls ) + except ValidationError: return handle_partial_json( result, model, bool(output_json), agent, converter_cls ) + except Exception as e: Printer().print( content=f"Unexpected error during model conversion: {type(e).__name__}: {e}. Returning original result.", diff --git a/src/crewai/utilities/pydantic_schema_parser.py b/src/crewai/utilities/pydantic_schema_parser.py index 9d9cdabe8..073280dd2 100644 --- a/src/crewai/utilities/pydantic_schema_parser.py +++ b/src/crewai/utilities/pydantic_schema_parser.py @@ -1,4 +1,4 @@ -from typing import Type, get_args, get_origin +from typing import Type, get_args, get_origin, Union from pydantic import BaseModel @@ -36,7 +36,14 @@ class PydanticSchemaParser(BaseModel): return f"List[\n{nested_schema}\n{' ' * 4 * depth}]" else: return f"List[{list_item_type.__name__}]" - elif issubclass(field_type, BaseModel): + elif get_origin(field_type) is Union: + union_args = get_args(field_type) + if type(None) in union_args: + non_none_type = next(arg for arg in union_args if arg is not type(None)) + return f"Optional[{self._get_field_type(field.__class__(annotation=non_none_type), depth)}]" + else: + return f"Union[{', '.join(arg.__name__ for arg in union_args)}]" + elif isinstance(field_type, type) and issubclass(field_type, BaseModel): return self._get_model_schema(field_type, depth) else: - return field_type.__name__ + return getattr(field_type, "__name__", str(field_type)) From 5113bca0257b8bcfb9dfb71b171240fa57685d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Tue, 1 Oct 2024 14:18:17 -0700 Subject: [PATCH 4/6] preparing new version --- pyproject.toml | 2 +- src/crewai/cli/templates/crew/pyproject.toml | 2 +- src/crewai/cli/templates/flow/pyproject.toml | 2 +- src/crewai/cli/templates/pipeline/pyproject.toml | 2 +- src/crewai/cli/templates/pipeline_router/pyproject.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5600ccea8..dcf1a3770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "crewai" -version = "0.67.0" +version = "0.67.1" description = "Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks." authors = ["Joao Moura "] readme = "README.md" diff --git a/src/crewai/cli/templates/crew/pyproject.toml b/src/crewai/cli/templates/crew/pyproject.toml index 41534fd27..6cb533fc8 100644 --- a/src/crewai/cli/templates/crew/pyproject.toml +++ b/src/crewai/cli/templates/crew/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" } [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/flow/pyproject.toml b/src/crewai/cli/templates/flow/pyproject.toml index 204c8f642..9cebbc497 100644 --- a/src/crewai/cli/templates/flow/pyproject.toml +++ b/src/crewai/cli/templates/flow/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" } asyncio = "*" [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/pipeline/pyproject.toml b/src/crewai/cli/templates/pipeline/pyproject.toml index b9a68a643..cafc79b8a 100644 --- a/src/crewai/cli/templates/pipeline/pyproject.toml +++ b/src/crewai/cli/templates/pipeline/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" } asyncio = "*" [tool.poetry.scripts] diff --git a/src/crewai/cli/templates/pipeline_router/pyproject.toml b/src/crewai/cli/templates/pipeline_router/pyproject.toml index ca7618b2d..4e5655510 100644 --- a/src/crewai/cli/templates/pipeline_router/pyproject.toml +++ b/src/crewai/cli/templates/pipeline_router/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Your Name "] [tool.poetry.dependencies] python = ">=3.10,<=3.13" -crewai = { extras = ["tools"], version = ">=0.67.0,<1.0.0" } +crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" } [tool.poetry.scripts] From 0e11b33f6e273ae609cc8cb8a2b9531814ff322a Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Tue, 1 Oct 2024 18:44:08 -0300 Subject: [PATCH 5/6] Change Tool Repository authentication scope (#1378) This commit adds a new command for adding custom PyPI indexes credentials to the project. This was changed because credentials are now user-scoped instead of organization. --- src/crewai/cli/cli.py | 2 + src/crewai/cli/command.py | 45 +++++++++++-- src/crewai/cli/deploy/main.py | 28 +++----- src/crewai/cli/plus_api.py | 3 + src/crewai/cli/tools/main.py | 50 +++++++------- src/crewai/cli/utils.py | 27 ++------ tests/cli/deploy/test_deploy_main.py | 99 ++++++++++++++++++---------- tests/cli/test_plus_api.py | 23 ++++--- tests/cli/tools/test_main.py | 93 ++++++++++++++++---------- 9 files changed, 220 insertions(+), 150 deletions(-) diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index de6160ba6..df2064da5 100644 --- a/src/crewai/cli/cli.py +++ b/src/crewai/cli/cli.py @@ -264,6 +264,7 @@ def deploy_remove(uuid: Optional[str]): @click.argument("handle") def tool_install(handle: str): tool_cmd = ToolCommand() + tool_cmd.login() tool_cmd.install(handle) @@ -272,6 +273,7 @@ def tool_install(handle: str): @click.option("--private", "is_public", flag_value=False) def tool_publish(is_public: bool): tool_cmd = ToolCommand() + tool_cmd.login() tool_cmd.publish(is_public) diff --git a/src/crewai/cli/command.py b/src/crewai/cli/command.py index 0b12b9082..f05fe237f 100644 --- a/src/crewai/cli/command.py +++ b/src/crewai/cli/command.py @@ -1,4 +1,5 @@ -from typing import Dict, Any +import requests +from requests.exceptions import JSONDecodeError from rich.console import Console from crewai.cli.plus_api import PlusAPI from crewai.cli.utils import get_auth_token @@ -27,14 +28,44 @@ class PlusAPIMixin: console.print("Run 'crewai signup' to sign up/login.", style="bold green") raise SystemExit - def _handle_plus_api_error(self, json_response: Dict[str, Any]) -> None: + def _validate_response(self, response: requests.Response) -> None: """ Handle and display error messages from API responses. Args: - json_response (Dict[str, Any]): The JSON response containing error information. + response (requests.Response): The response from the Plus API """ - error = json_response.get("error", "Unknown error") - message = json_response.get("message", "No message provided") - console.print(f"Error: {error}", style="bold red") - console.print(f"Message: {message}", style="bold red") + try: + json_response = response.json() + except (JSONDecodeError, ValueError): + console.print( + "Failed to parse response from Enterprise API failed. Details:", + style="bold red", + ) + console.print(f"Status Code: {response.status_code}") + console.print(f"Response:\n{response.content}") + raise SystemExit + + if response.status_code == 422: + console.print( + "Failed to complete operation. Please fix the following errors:", + style="bold red", + ) + for field, messages in json_response.items(): + for message in messages: + console.print( + f"* [bold red]{field.capitalize()}[/bold red] {message}" + ) + raise SystemExit + + if not response.ok: + console.print( + "Request to Enterprise API failed. Details:", style="bold red" + ) + details = ( + json_response.get("error") + or json_response.get("message") + or response.content + ) + console.print(f"{details}") + raise SystemExit diff --git a/src/crewai/cli/deploy/main.py b/src/crewai/cli/deploy/main.py index d6c9d8fe6..0625ad82e 100644 --- a/src/crewai/cli/deploy/main.py +++ b/src/crewai/cli/deploy/main.py @@ -79,11 +79,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._standard_no_param_error_message() return - json_response = response.json() - if response.status_code == 200: - self._display_deployment_info(json_response) - else: - self._handle_plus_api_error(json_response) + self._validate_response(response) + self._display_deployment_info(response.json()) def create_crew(self, confirm: bool = False) -> None: """ @@ -106,12 +103,10 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._confirm_input(env_vars, remote_repo_url, confirm) payload = self._create_payload(env_vars, remote_repo_url) - response = self.plus_api_client.create_crew(payload) - if response.status_code == 201: - self._display_creation_success(response.json()) - else: - self._handle_plus_api_error(response.json()) + + self._validate_response(response) + self._display_creation_success(response.json()) def _confirm_input( self, env_vars: Dict[str, str], remote_repo_url: str, confirm: bool @@ -218,11 +213,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._standard_no_param_error_message() return - json_response = response.json() - if response.status_code == 200: - self._display_crew_status(json_response) - else: - self._handle_plus_api_error(json_response) + self._validate_response(response) + self._display_crew_status(response.json()) def _display_crew_status(self, status_data: Dict[str, str]) -> None: """ @@ -253,10 +245,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._standard_no_param_error_message() return - if response.status_code == 200: - self._display_logs(response.json()) - else: - self._handle_plus_api_error(response.json()) + self._validate_response(response) + self._display_logs(response.json()) def remove_crew(self, uuid: Optional[str]) -> None: """ diff --git a/src/crewai/cli/plus_api.py b/src/crewai/cli/plus_api.py index e72d27bfe..0fa9ff3aa 100644 --- a/src/crewai/cli/plus_api.py +++ b/src/crewai/cli/plus_api.py @@ -27,6 +27,9 @@ class PlusAPI: url = urljoin(self.base_url, endpoint) return requests.request(method, url, headers=self.headers, **kwargs) + def login_to_tool_repository(self): + return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login") + def get_tool(self, handle: str): return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}") diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py index 8acbcedd5..7f6368e2d 100644 --- a/src/crewai/cli/tools/main.py +++ b/src/crewai/cli/tools/main.py @@ -64,23 +64,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin): description=project_description, encoded_file=f"data:application/x-gzip;base64,{encoded_tarball}", ) - if publish_response.status_code == 422: - console.print( - "[bold red]Failed to publish tool. Please fix the following errors:[/bold red]" - ) - for field, messages in publish_response.json().items(): - for message in messages: - console.print( - f"* [bold red]{field.capitalize()}[/bold red] {message}" - ) - raise SystemExit - elif publish_response.status_code != 200: - self._handle_plus_api_error(publish_response.json()) - console.print( - "Failed to publish tool. Please try again later.", style="bold red" - ) - raise SystemExit + self._validate_response(publish_response) published_handle = publish_response.json()["handle"] console.print( @@ -103,15 +88,32 @@ class ToolCommand(BaseCommand, PlusAPIMixin): ) raise SystemExit - self._add_repository_to_poetry(get_response.json()) self._add_package(get_response.json()) console.print(f"Succesfully installed {handle}", style="bold green") - def _add_repository_to_poetry(self, tool_details): - repository_handle = f"crewai-{tool_details['repository']['handle']}" - repository_url = tool_details["repository"]["url"] - repository_credentials = tool_details["repository"]["credentials"] + def login(self): + login_response = self.plus_api_client.login_to_tool_repository() + + if login_response.status_code != 200: + console.print( + "Failed to authenticate to the tool repository. Make sure you have the access to tools.", + style="bold red", + ) + raise SystemExit + + login_response_json = login_response.json() + for repository in login_response_json["repositories"]: + self._add_repository_to_poetry( + repository, login_response_json["credential"] + ) + + console.print( + "Succesfully authenticated to the tool repository.", style="bold green" + ) + + def _add_repository_to_poetry(self, repository, credentials): + repository_handle = f"crewai-{repository['handle']}" add_repository_command = [ "poetry", @@ -119,7 +121,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): "add", "--priority=explicit", repository_handle, - repository_url, + repository["url"], ] add_repository_result = subprocess.run( add_repository_command, text=True, check=True @@ -133,8 +135,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin): "poetry", "config", f"http-basic.{repository_handle}", - repository_credentials, - '""', + credentials["username"], + credentials["password"], ] add_repository_credentials_result = subprocess.run( add_repository_credentials_command, diff --git a/src/crewai/cli/utils.py b/src/crewai/cli/utils.py index 58aa154dd..f5e9c4192 100644 --- a/src/crewai/cli/utils.py +++ b/src/crewai/cli/utils.py @@ -2,6 +2,7 @@ import click import re import subprocess import sys +import importlib.metadata from crewai.cli.authentication.utils import TokenManager from functools import reduce @@ -162,29 +163,9 @@ def _get_nested_value(data: Dict[str, Any], keys: List[str]) -> Any: return reduce(dict.__getitem__, keys, data) -def get_crewai_version(poetry_lock_path: str = "poetry.lock") -> str: - """Get the version number of crewai from the poetry.lock file.""" - try: - with open(poetry_lock_path, "r") as f: - lock_content = f.read() - - match = re.search( - r'\[\[package\]\]\s*name\s*=\s*"crewai"\s*version\s*=\s*"([^"]+)"', - lock_content, - re.DOTALL, - ) - if match: - return match.group(1) - else: - print("crewai package not found in poetry.lock") - return "no-version-found" - - except FileNotFoundError: - print(f"Error: {poetry_lock_path} not found.") - except Exception as e: - print(f"Error reading the poetry.lock file: {e}") - - return "no-version-found" +def get_crewai_version() -> str: + """Get the version number of CrewAI running the CLI""" + return importlib.metadata.version("crewai") def fetch_and_json_env_file(env_file_path: str = ".env") -> dict: diff --git a/tests/cli/deploy/test_deploy_main.py b/tests/cli/deploy/test_deploy_main.py index ddb9709d3..c32d254cb 100644 --- a/tests/cli/deploy/test_deploy_main.py +++ b/tests/cli/deploy/test_deploy_main.py @@ -1,7 +1,11 @@ -import unittest -from io import StringIO -from unittest.mock import MagicMock, patch +import pytest +import requests import sys +import unittest + +from io import StringIO +from requests.exceptions import JSONDecodeError +from unittest.mock import MagicMock, Mock, patch from crewai.cli.deploy.main import DeployCommand from crewai.cli.utils import parse_toml @@ -33,13 +37,65 @@ class TestDeployCommand(unittest.TestCase): with self.assertRaises(SystemExit): DeployCommand() - def test_handle_plus_api_error(self): + def test_validate_response_successful_response(self): + mock_response = Mock(spec=requests.Response) + mock_response.json.return_value = {"message": "Success"} + mock_response.status_code = 200 + mock_response.ok = True + with patch("sys.stdout", new=StringIO()) as fake_out: - self.deploy_command._handle_plus_api_error( - {"error": "Test error", "message": "Test message"} + self.deploy_command._validate_response(mock_response) + assert fake_out.getvalue() == "" + + def test_validate_response_json_decode_error(self): + mock_response = Mock(spec=requests.Response) + mock_response.json.side_effect = JSONDecodeError("Decode error", "", 0) + mock_response.status_code = 500 + mock_response.content = b"Invalid JSON" + + with patch("sys.stdout", new=StringIO()) as fake_out: + with pytest.raises(SystemExit): + self.deploy_command._validate_response(mock_response) + output = fake_out.getvalue() + assert ( + "Failed to parse response from Enterprise API failed. Details:" + in output ) - self.assertIn("Error: Test error", fake_out.getvalue()) - self.assertIn("Message: Test message", fake_out.getvalue()) + assert "Status Code: 500" in output + assert "Response:\nb'Invalid JSON'" in output + + def test_validate_response_422_error(self): + mock_response = Mock(spec=requests.Response) + mock_response.json.return_value = { + "field1": ["Error message 1"], + "field2": ["Error message 2"], + } + mock_response.status_code = 422 + mock_response.ok = False + + with patch("sys.stdout", new=StringIO()) as fake_out: + with pytest.raises(SystemExit): + self.deploy_command._validate_response(mock_response) + output = fake_out.getvalue() + assert ( + "Failed to complete operation. Please fix the following errors:" + in output + ) + assert "Field1 Error message 1" in output + assert "Field2 Error message 2" in output + + def test_validate_response_other_error(self): + mock_response = Mock(spec=requests.Response) + mock_response.json.return_value = {"error": "Something went wrong"} + mock_response.status_code = 500 + mock_response.ok = False + + with patch("sys.stdout", new=StringIO()) as fake_out: + with pytest.raises(SystemExit): + self.deploy_command._validate_response(mock_response) + output = fake_out.getvalue() + assert "Request to Enterprise API failed. Details:" in output + assert "Details:\nSomething went wrong" in output def test_standard_no_param_error_message(self): with patch("sys.stdout", new=StringIO()) as fake_out: @@ -207,30 +263,7 @@ class TestDeployCommand(unittest.TestCase): project_name = get_project_name() self.assertEqual(project_name, "test_project") - @patch( - "builtins.open", - new_callable=unittest.mock.mock_open, - read_data=""" - [[package]] - name = "crewai" - version = "0.51.1" - description = "Some description" - category = "main" - optional = false - python-versions = ">=3.10,<4.0" - """, - ) - def test_get_crewai_version(self, mock_open): + def test_get_crewai_version(self): from crewai.cli.utils import get_crewai_version - version = get_crewai_version() - self.assertEqual(version, "0.51.1") - - @patch("builtins.open", side_effect=FileNotFoundError) - def test_get_crewai_version_file_not_found(self, mock_open): - from crewai.cli.utils import get_crewai_version - - with patch("sys.stdout", new=StringIO()) as fake_out: - version = get_crewai_version() - self.assertEqual(version, "no-version-found") - self.assertIn("Error: poetry.lock not found.", fake_out.getvalue()) + assert isinstance(get_crewai_version(), str) diff --git a/tests/cli/test_plus_api.py b/tests/cli/test_plus_api.py index 506246290..ace9e4e58 100644 --- a/tests/cli/test_plus_api.py +++ b/tests/cli/test_plus_api.py @@ -11,15 +11,22 @@ class TestPlusAPI(unittest.TestCase): def test_init(self): self.assertEqual(self.api.api_key, self.api_key) - self.assertEqual( - self.api.headers, - { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "User-Agent": "CrewAI-CLI/no-version-found", - "X-Crewai-Version": "no-version-found", - }, + self.assertEqual(self.api.headers["Authorization"], f"Bearer {self.api_key}") + self.assertEqual(self.api.headers["Content-Type"], "application/json") + self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"]) + self.assertTrue(self.api.headers["X-Crewai-Version"]) + + @patch("crewai.cli.plus_api.PlusAPI._make_request") + def test_login_to_tool_repository(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + + response = self.api.login_to_tool_repository() + + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/tools/login" ) + self.assertEqual(response, mock_response) @patch("crewai.cli.plus_api.PlusAPI._make_request") def test_get_tool(self, mock_make_request): diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py index f387c8d3f..66521f9b3 100644 --- a/tests/cli/tools/test_main.py +++ b/tests/cli/tools/test_main.py @@ -13,11 +13,7 @@ class TestToolCommand(unittest.TestCase): mock_get_response.status_code = 200 mock_get_response.json.return_value = { "handle": "sample-tool", - "repository": { - "handle": "sample-repo", - "url": "https://example.com/repo", - "credentials": "my_very_secret", - }, + "repository": {"handle": "sample-repo", "url": "https://example.com/repo"}, } mock_get.return_value = mock_get_response mock_subprocess_run.return_value = MagicMock(stderr=None) @@ -29,30 +25,6 @@ class TestToolCommand(unittest.TestCase): output = fake_out.getvalue() mock_get.assert_called_once_with("sample-tool") - mock_subprocess_run.assert_any_call( - [ - "poetry", - "source", - "add", - "--priority=explicit", - "crewai-sample-repo", - "https://example.com/repo", - ], - text=True, - check=True, - ) - mock_subprocess_run.assert_any_call( - [ - "poetry", - "config", - "http-basic.crewai-sample-repo", - "my_very_secret", - '""', - ], - capture_output=False, - text=True, - check=True, - ) mock_subprocess_run.assert_any_call( ["poetry", "add", "--source", "crewai-sample-repo", "sample-tool"], capture_output=False, @@ -182,7 +154,7 @@ class TestToolCommand(unittest.TestCase): output = fake_out.getvalue() mock_publish.assert_called_once() - self.assertIn("Failed to publish tool", output) + self.assertIn("Failed to complete operation", output) self.assertIn("Name is already taken", output) @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") @@ -210,9 +182,11 @@ class TestToolCommand(unittest.TestCase): mock_get_project_version, mock_get_project_name, ): - mock_publish_response = MagicMock() - mock_publish_response.status_code = 500 - mock_publish.return_value = mock_publish_response + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.return_value = {"error": "Internal Server Error"} + mock_response.ok = False + mock_publish.return_value = mock_response tool_command = ToolCommand() @@ -222,8 +196,55 @@ class TestToolCommand(unittest.TestCase): output = fake_out.getvalue() mock_publish.assert_called_once() - self.assertIn("Failed to publish tool", output) + self.assertIn("Request to Enterprise API failed", output) + @patch("crewai.cli.plus_api.PlusAPI.login_to_tool_repository") + @patch("crewai.cli.tools.main.subprocess.run") + def test_login_success(self, mock_subprocess_run, mock_login): + mock_login_response = MagicMock() + mock_login_response.status_code = 200 + mock_login_response.json.return_value = { + "repositories": [ + { + "handle": "tools", + "url": "https://example.com/repo", + } + ], + "credential": {"username": "user", "password": "pass"}, + } + mock_login.return_value = mock_login_response -if __name__ == "__main__": - unittest.main() + mock_subprocess_run.return_value = MagicMock(stderr=None) + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + tool_command.login() + output = fake_out.getvalue() + + mock_login.assert_called_once() + mock_subprocess_run.assert_any_call( + [ + "poetry", + "source", + "add", + "--priority=explicit", + "crewai-tools", + "https://example.com/repo", + ], + text=True, + check=True, + ) + mock_subprocess_run.assert_any_call( + [ + "poetry", + "config", + "http-basic.crewai-tools", + "user", + "pass", + ], + capture_output=False, + text=True, + check=True, + ) + self.assertIn("Succesfully authenticated to the tool repository", output) From 01329a01abf16be7774e2cba18c62b757ce97760 Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Tue, 1 Oct 2024 18:58:27 -0300 Subject: [PATCH 6/6] Create `crewai tool create command` (#1379) This commit creates a new CLI command for scaffolding tools. --- src/crewai/cli/cli.py | 7 ++ src/crewai/cli/templates/tool/README.md | 48 +++++++++ src/crewai/cli/templates/tool/pyproject.toml | 14 +++ .../tool/src/{{folder_name}}/__init__.py | 0 .../tool/src/{{folder_name}}/tool.py | 9 ++ src/crewai/cli/tools/main.py | 47 ++++++++ src/crewai/cli/utils.py | 39 +++++++ tests/cli/__init__.py | 0 tests/cli/authentication/__init__.py | 0 tests/cli/test_utils.py | 100 ++++++++++++++++++ tests/cli/tools/test_main.py | 48 +++++++++ 11 files changed, 312 insertions(+) create mode 100644 src/crewai/cli/templates/tool/README.md create mode 100644 src/crewai/cli/templates/tool/pyproject.toml create mode 100644 src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py create mode 100644 src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/authentication/__init__.py create mode 100644 tests/cli/test_utils.py diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index df2064da5..c8660f346 100644 --- a/src/crewai/cli/cli.py +++ b/src/crewai/cli/cli.py @@ -260,6 +260,13 @@ def deploy_remove(uuid: Optional[str]): deploy_cmd.remove_crew(uuid=uuid) +@tool.command(name="create") +@click.argument("handle") +def tool_create(handle: str): + tool_cmd = ToolCommand() + tool_cmd.create(handle) + + @tool.command(name="install") @click.argument("handle") def tool_install(handle: str): diff --git a/src/crewai/cli/templates/tool/README.md b/src/crewai/cli/templates/tool/README.md new file mode 100644 index 000000000..65cb0ffb2 --- /dev/null +++ b/src/crewai/cli/templates/tool/README.md @@ -0,0 +1,48 @@ +# {{folder_name}} + +{{folder_name}} is a CrewAI Tool. This template is designed to help you create +custom tools to power up your crews. + +## Installing + +Ensure you have Python >=3.10 <=3.13 installed on your system. This project +uses [Poetry](https://python-poetry.org/) for dependency management and package +handling, offering a seamless setup and execution experience. + +First, if you haven't already, install Poetry: + +```bash +pip install poetry +``` + +Next, navigate to your project directory and install the dependencies with: + +```bash +crewai install +``` + +## Publishing + +Collaborate by sharing tools within your organization, or publish them publicly +to contribute with the community. + +```bash +crewai tool publish {{tool_name}} +``` + +Others may install your tool in their crews running: + +```bash +crewai tool install {{tool_name}} +``` + +## Support + +For support, questions, or feedback regarding the {{crew_name}} tool or CrewAI. + +- Visit our [documentation](https://docs.crewai.com) +- Reach out to us through our [GitHub repository](https://github.com/joaomdmoura/crewai) +- [Join our Discord](https://discord.com/invite/X4JWnZnxPb) +- [Chat with our docs](https://chatg.pt/DWjSBZn) + +Let's create wonders together with the power and simplicity of crewAI. diff --git a/src/crewai/cli/templates/tool/pyproject.toml b/src/crewai/cli/templates/tool/pyproject.toml new file mode 100644 index 000000000..d02a858ec --- /dev/null +++ b/src/crewai/cli/templates/tool/pyproject.toml @@ -0,0 +1,14 @@ +[tool.poetry] +name = "{{folder_name}}" +version = "0.1.0" +description = "Power up your crews with {{folder_name}}" +authors = ["Your Name "] +readme = "README.md" + +[tool.poetry.dependencies] +python = ">=3.10,<=3.13" +crewai = { extras = ["tools"], version = ">=0.64.0,<1.0.0" } + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py b/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py b/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py new file mode 100644 index 000000000..63c653a6c --- /dev/null +++ b/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py @@ -0,0 +1,9 @@ +from crewai_tools import BaseTool + +class {{class_name}}(BaseTool): + name: str = "Name of my tool" + description: str = "What this tool does. It's vital for effective utilization." + + def _run(self, argument: str) -> str: + # Your tool's logic here + return "Tool's result" diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py index 7f6368e2d..45c6e5d46 100644 --- a/src/crewai/cli/tools/main.py +++ b/src/crewai/cli/tools/main.py @@ -1,4 +1,5 @@ import base64 +from pathlib import Path import click import os import subprocess @@ -9,6 +10,8 @@ from crewai.cli.utils import ( get_project_name, get_project_description, get_project_version, + tree_copy, + tree_find_and_replace, ) from rich.console import Console @@ -24,6 +27,37 @@ class ToolCommand(BaseCommand, PlusAPIMixin): BaseCommand.__init__(self) PlusAPIMixin.__init__(self, telemetry=self._telemetry) + def create(self, handle: str): + self._ensure_not_in_project() + + folder_name = handle.replace(" ", "_").replace("-", "_").lower() + class_name = handle.replace("_", " ").replace("-", " ").title().replace(" ", "") + + project_root = Path(folder_name) + if project_root.exists(): + click.secho(f"Folder {folder_name} already exists.", fg="red") + raise SystemExit + else: + os.makedirs(project_root) + + click.secho(f"Creating custom tool {folder_name}...", fg="green", bold=True) + + template_dir = Path(__file__).parent.parent / "templates" / "tool" + tree_copy(template_dir, project_root) + tree_find_and_replace(project_root, "{{folder_name}}", folder_name) + tree_find_and_replace(project_root, "{{class_name}}", class_name) + + old_directory = os.getcwd() + os.chdir(project_root) + try: + self.login() + subprocess.run(["git", "init"], check=True) + console.print( + f"[green]Created custom tool [bold]{folder_name}[/bold]. Run [bold]cd {project_root}[/bold] to start working.[/green]" + ) + finally: + os.chdir(old_directory) + def publish(self, is_public: bool): project_name = get_project_name(require=True) assert isinstance(project_name, str) @@ -168,3 +202,16 @@ class ToolCommand(BaseCommand, PlusAPIMixin): if add_package_result.stderr: click.echo(add_package_result.stderr, err=True) raise SystemExit + + def _ensure_not_in_project(self): + if os.path.isfile("./pyproject.toml"): + console.print( + "[bold red]Oops! It looks like you're inside a project.[/bold red]" + ) + console.print( + "You can't create a new tool while inside an existing project." + ) + console.print( + "[bold yellow]Tip:[/bold yellow] Navigate to a different directory and try again." + ) + raise SystemExit diff --git a/src/crewai/cli/utils.py b/src/crewai/cli/utils.py index f5e9c4192..6d25c4aeb 100644 --- a/src/crewai/cli/utils.py +++ b/src/crewai/cli/utils.py @@ -1,3 +1,5 @@ +import os +import shutil import click import re import subprocess @@ -198,3 +200,40 @@ def get_auth_token() -> str: if not access_token: raise Exception() return access_token + + +def tree_copy(source, destination): + """Copies the entire directory structure from the source to the destination.""" + for item in os.listdir(source): + source_item = os.path.join(source, item) + destination_item = os.path.join(destination, item) + if os.path.isdir(source_item): + shutil.copytree(source_item, destination_item) + else: + shutil.copy2(source_item, destination_item) + + +def tree_find_and_replace(directory, find, replace): + """Recursively searches through a directory, replacing a target string in + both file contents and filenames with a specified replacement string. + """ + for path, dirs, files in os.walk(os.path.abspath(directory), topdown=False): + for filename in files: + filepath = os.path.join(path, filename) + + with open(filepath, "r") as file: + contents = file.read() + with open(filepath, "w") as file: + file.write(contents.replace(find, replace)) + + if find in filename: + new_filename = filename.replace(find, replace) + new_filepath = os.path.join(path, new_filename) + os.rename(filepath, new_filepath) + + for dirname in dirs: + if find in dirname: + new_dirname = dirname.replace(find, replace) + new_dirpath = os.path.join(path, new_dirname) + old_dirpath = os.path.join(path, dirname) + os.rename(old_dirpath, new_dirpath) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/authentication/__init__.py b/tests/cli/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py new file mode 100644 index 000000000..616fb3d2f --- /dev/null +++ b/tests/cli/test_utils.py @@ -0,0 +1,100 @@ +import pytest +import shutil +import tempfile +import os +from crewai.cli import utils + + +@pytest.fixture +def temp_tree(): + root_dir = tempfile.mkdtemp() + + create_file(os.path.join(root_dir, "file1.txt"), "Hello, world!") + create_file(os.path.join(root_dir, "file2.txt"), "Another file") + os.mkdir(os.path.join(root_dir, "empty_dir")) + nested_dir = os.path.join(root_dir, "nested_dir") + os.mkdir(nested_dir) + create_file(os.path.join(nested_dir, "nested_file.txt"), "Nested content") + + yield root_dir + + shutil.rmtree(root_dir) + + +def create_file(path, content): + with open(path, "w") as f: + f.write(content) + + +def test_tree_find_and_replace_file_content(temp_tree): + utils.tree_find_and_replace(temp_tree, "world", "universe") + with open(os.path.join(temp_tree, "file1.txt"), "r") as f: + assert f.read() == "Hello, universe!" + + +def test_tree_find_and_replace_file_name(temp_tree): + old_path = os.path.join(temp_tree, "file2.txt") + new_path = os.path.join(temp_tree, "file2_renamed.txt") + os.rename(old_path, new_path) + utils.tree_find_and_replace(temp_tree, "renamed", "modified") + assert os.path.exists(os.path.join(temp_tree, "file2_modified.txt")) + assert not os.path.exists(new_path) + + +def test_tree_find_and_replace_directory_name(temp_tree): + utils.tree_find_and_replace(temp_tree, "empty", "renamed") + assert os.path.exists(os.path.join(temp_tree, "renamed_dir")) + assert not os.path.exists(os.path.join(temp_tree, "empty_dir")) + + +def test_tree_find_and_replace_nested_content(temp_tree): + utils.tree_find_and_replace(temp_tree, "Nested", "Updated") + with open(os.path.join(temp_tree, "nested_dir", "nested_file.txt"), "r") as f: + assert f.read() == "Updated content" + + +def test_tree_find_and_replace_no_matches(temp_tree): + utils.tree_find_and_replace(temp_tree, "nonexistent", "replacement") + assert set(os.listdir(temp_tree)) == { + "file1.txt", + "file2.txt", + "empty_dir", + "nested_dir", + } + + +def test_tree_copy_full_structure(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + utils.tree_copy(temp_tree, dest_dir) + assert set(os.listdir(dest_dir)) == set(os.listdir(temp_tree)) + assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) + assert os.path.isfile(os.path.join(dest_dir, "file2.txt")) + assert os.path.isdir(os.path.join(dest_dir, "empty_dir")) + assert os.path.isdir(os.path.join(dest_dir, "nested_dir")) + assert os.path.isfile(os.path.join(dest_dir, "nested_dir", "nested_file.txt")) + finally: + shutil.rmtree(dest_dir) + + +def test_tree_copy_preserve_content(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + utils.tree_copy(temp_tree, dest_dir) + with open(os.path.join(dest_dir, "file1.txt"), "r") as f: + assert f.read() == "Hello, world!" + with open(os.path.join(dest_dir, "nested_dir", "nested_file.txt"), "r") as f: + assert f.read() == "Nested content" + finally: + shutil.rmtree(dest_dir) + + +def test_tree_copy_to_existing_directory(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + create_file(os.path.join(dest_dir, "existing_file.txt"), "I was here first") + utils.tree_copy(temp_tree, dest_dir) + assert os.path.isfile(os.path.join(dest_dir, "existing_file.txt")) + assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) + finally: + shutil.rmtree(dest_dir) diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py index 66521f9b3..ec1d71668 100644 --- a/tests/cli/tools/test_main.py +++ b/tests/cli/tools/test_main.py @@ -1,11 +1,59 @@ +from contextlib import contextmanager +import tempfile import unittest import unittest.mock +import os from crewai.cli.tools.main import ToolCommand from io import StringIO from unittest.mock import patch, MagicMock class TestToolCommand(unittest.TestCase): + @contextmanager + def in_temp_dir(self): + original_dir = os.getcwd() + with tempfile.TemporaryDirectory() as temp_dir: + os.chdir(temp_dir) + try: + yield temp_dir + finally: + os.chdir(original_dir) + + @patch("crewai.cli.tools.main.subprocess.run") + def test_create_success(self, mock_subprocess): + with self.in_temp_dir(): + tool_command = ToolCommand() + + with patch.object(tool_command, "login") as mock_login, patch( + "sys.stdout", new=StringIO() + ) as fake_out: + tool_command.create("test-tool") + output = fake_out.getvalue() + + self.assertTrue(os.path.isdir("test_tool")) + + self.assertTrue(os.path.isfile(os.path.join("test_tool", "README.md"))) + self.assertTrue(os.path.isfile(os.path.join("test_tool", "pyproject.toml"))) + self.assertTrue( + os.path.isfile( + os.path.join("test_tool", "src", "test_tool", "__init__.py") + ) + ) + self.assertTrue( + os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py")) + ) + + with open( + os.path.join("test_tool", "src", "test_tool", "tool.py"), "r" + ) as f: + content = f.read() + self.assertIn("class TestTool", content) + + mock_login.assert_called_once() + mock_subprocess.assert_called_once_with(["git", "init"], check=True) + + self.assertIn("Creating custom tool test_tool...", output) + @patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.plus_api.PlusAPI.get_tool") def test_install_success(self, mock_get, mock_subprocess_run):