mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-09 08:08:32 +00:00
* fix: normalize project names by stripping trailing slashes in crew creation - Strip trailing slashes from project names in create_folder_structure - Add comprehensive tests for trailing slash scenarios - Fixes #3059 The issue occurred because trailing slashes in project names like 'hello/' were directly incorporated into pyproject.toml, creating invalid package names and script entries. This fix silently normalizes project names by stripping trailing slashes before processing, maintaining backward compatibility while fixing the invalid template generation. Co-Authored-By: João <joao@crewai.com> * trigger CI re-run to check for flaky test issue Co-Authored-By: João <joao@crewai.com> * fix: resolve circular import in CLI authentication module - Move ToolCommand import to be local inside _poll_for_token method - Update test mock to patch ToolCommand at correct location - Resolves Python 3.11 test collection failure in CI Co-Authored-By: João <joao@crewai.com> * feat: add comprehensive class name validation for Python identifiers - Ensure generated class names are always valid Python identifiers - Handle edge cases: names starting with numbers, special characters, keywords, built-ins - Add sanitization logic to remove invalid characters and prefix with 'Crew' when needed - Add comprehensive test coverage for class name validation edge cases - Addresses GitHub PR comment from lucasgomide about class name validity Fixes include: - Names starting with numbers: '123project' -> 'Crew123Project' - Python built-ins: 'True' -> 'TrueCrew', 'False' -> 'FalseCrew' - Special characters: 'hello@world' -> 'HelloWorld' - Empty/whitespace: ' ' -> 'DefaultCrew' - All generated class names pass isidentifier() and keyword checks Co-Authored-By: João <joao@crewai.com> * refactor: change class name validation to raise errors instead of generating defaults - Remove default value generation (Crew prefix/suffix, DefaultCrew fallback) - Raise ValueError with descriptive messages for invalid class names - Update tests to expect validation errors instead of default corrections - Addresses GitHub comment feedback from lucasgomide about strict validation Co-Authored-By: João <joao@crewai.com> * fix: add working directory safety checks to prevent test interference Co-Authored-By: João <joao@crewai.com> * fix: standardize working directory handling in tests to prevent corruption Co-Authored-By: João <joao@crewai.com> * fix: eliminate os.chdir() usage in tests to prevent working directory corruption - Replace os.chdir() with parent_folder parameter for create_folder_structure tests - Mock create_folder_structure directly for create_crew tests to avoid directory changes - All 12 tests now pass locally without working directory corruption - Should resolve the 103 failing tests in Python 3.12 CI Co-Authored-By: João <joao@crewai.com> * fix: remove unused os import to resolve lint failure - Remove unused 'import os' statement from test_create_crew.py - All tests still pass locally after removing unused import - Should resolve F401 lint error in CI Co-Authored-By: João <joao@crewai.com> * feat: add folder name validation for Python module names - Implement validation to ensure folder_name is valid Python identifier - Check that folder names don't start with digits - Validate folder names are not Python keywords - Sanitize invalid characters from folder names - Raise ValueError with descriptive messages for invalid cases - Update tests to validate both folder and class name requirements - Addresses GitHub comment requiring folder names to be valid Python module names Co-Authored-By: João <joao@crewai.com> * fix: correct folder name validation logic to match test expectations - Fix validation regex to catch names starting with invalid characters like '@#/' - Ensure validation properly raises ValueError for cases expected by tests - Maintain support for valid cases like 'my.project/' -> 'myproject' - Address lucasgomide's comment about valid Python module names Co-Authored-By: João <joao@crewai.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: João <joao@crewai.com> Co-authored-by: Lucas Gomide <lucaslg200@gmail.com>
250 lines
9.7 KiB
Python
250 lines
9.7 KiB
Python
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from crewai.cli.constants import ENV_VARS, MODELS
|
|
from crewai.cli.provider import (
|
|
get_provider_data,
|
|
select_model,
|
|
select_provider,
|
|
)
|
|
from crewai.cli.utils import copy_template, load_env_vars, write_env_file
|
|
|
|
|
|
def create_folder_structure(name, parent_folder=None):
|
|
import keyword
|
|
import re
|
|
|
|
name = name.rstrip('/')
|
|
|
|
if not name.strip():
|
|
raise ValueError("Project name cannot be empty or contain only whitespace")
|
|
|
|
folder_name = name.replace(" ", "_").replace("-", "_").lower()
|
|
folder_name = re.sub(r'[^a-zA-Z0-9_]', '', folder_name)
|
|
|
|
# Check if the name starts with invalid characters or is primarily invalid
|
|
if re.match(r'^[^a-zA-Z0-9_-]+', name):
|
|
raise ValueError(f"Project name '{name}' contains no valid characters for a Python module name")
|
|
|
|
if not folder_name:
|
|
raise ValueError(f"Project name '{name}' contains no valid characters for a Python module name")
|
|
|
|
if folder_name[0].isdigit():
|
|
raise ValueError(f"Project name '{name}' would generate folder name '{folder_name}' which cannot start with a digit (invalid Python module name)")
|
|
|
|
if keyword.iskeyword(folder_name):
|
|
raise ValueError(f"Project name '{name}' would generate folder name '{folder_name}' which is a reserved Python keyword")
|
|
|
|
if not folder_name.isidentifier():
|
|
raise ValueError(f"Project name '{name}' would generate invalid Python module name '{folder_name}'")
|
|
|
|
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
|
|
|
|
class_name = re.sub(r'[^a-zA-Z0-9_]', '', class_name)
|
|
|
|
if not class_name:
|
|
raise ValueError(f"Project name '{name}' contains no valid characters for a Python class name")
|
|
|
|
if class_name[0].isdigit():
|
|
raise ValueError(f"Project name '{name}' would generate class name '{class_name}' which cannot start with a digit")
|
|
|
|
# Check if the original name (before title casing) is a keyword
|
|
original_name_clean = re.sub(r'[^a-zA-Z0-9_]', '', name.replace("_", "").replace("-", "").lower())
|
|
if keyword.iskeyword(original_name_clean) or keyword.iskeyword(class_name) or class_name in ('True', 'False', 'None'):
|
|
raise ValueError(f"Project name '{name}' would generate class name '{class_name}' which is a reserved Python keyword")
|
|
|
|
if not class_name.isidentifier():
|
|
raise ValueError(f"Project name '{name}' would generate invalid Python class name '{class_name}'")
|
|
|
|
if parent_folder:
|
|
folder_path = Path(parent_folder) / folder_name
|
|
else:
|
|
folder_path = Path(folder_name)
|
|
|
|
if folder_path.exists():
|
|
if not click.confirm(
|
|
f"Folder {folder_name} already exists. Do you want to override it?"
|
|
):
|
|
click.secho("Operation cancelled.", fg="yellow")
|
|
sys.exit(0)
|
|
click.secho(f"Overriding folder {folder_name}...", fg="green", bold=True)
|
|
shutil.rmtree(folder_path) # Delete the existing folder and its contents
|
|
|
|
click.secho(
|
|
f"Creating {'crew' if parent_folder else 'folder'} {folder_name}...",
|
|
fg="green",
|
|
bold=True,
|
|
)
|
|
|
|
folder_path.mkdir(parents=True)
|
|
(folder_path / "tests").mkdir(exist_ok=True)
|
|
(folder_path / "knowledge").mkdir(exist_ok=True)
|
|
if not parent_folder:
|
|
(folder_path / "src" / folder_name).mkdir(parents=True)
|
|
(folder_path / "src" / folder_name / "tools").mkdir(parents=True)
|
|
(folder_path / "src" / folder_name / "config").mkdir(parents=True)
|
|
|
|
return folder_path, folder_name, class_name
|
|
|
|
|
|
def copy_template_files(folder_path, name, class_name, parent_folder):
|
|
package_dir = Path(__file__).parent
|
|
templates_dir = package_dir / "templates" / "crew"
|
|
|
|
root_template_files = (
|
|
[
|
|
".gitignore",
|
|
"pyproject.toml",
|
|
"README.md",
|
|
"knowledge/user_preference.txt",
|
|
]
|
|
if not parent_folder
|
|
else []
|
|
)
|
|
tools_template_files = ["tools/custom_tool.py", "tools/__init__.py"]
|
|
config_template_files = ["config/agents.yaml", "config/tasks.yaml"]
|
|
src_template_files = (
|
|
["__init__.py", "main.py", "crew.py"] if not parent_folder else ["crew.py"]
|
|
)
|
|
|
|
for file_name in root_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = folder_path / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
src_folder = (
|
|
folder_path / "src" / folder_path.name if not parent_folder else folder_path
|
|
)
|
|
|
|
for file_name in src_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
if not parent_folder:
|
|
for file_name in tools_template_files + config_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
|
|
def create_crew(name, provider=None, skip_provider=False, parent_folder=None):
|
|
folder_path, folder_name, class_name = create_folder_structure(name, parent_folder)
|
|
env_vars = load_env_vars(folder_path)
|
|
if not skip_provider:
|
|
if not provider:
|
|
provider_models = get_provider_data()
|
|
if not provider_models:
|
|
return
|
|
|
|
existing_provider = None
|
|
for provider, env_keys in ENV_VARS.items():
|
|
if any(
|
|
"key_name" in details and details["key_name"] in env_vars
|
|
for details in env_keys
|
|
):
|
|
existing_provider = provider
|
|
break
|
|
|
|
if existing_provider:
|
|
if not click.confirm(
|
|
f"Found existing environment variable configuration for {existing_provider.capitalize()}. Do you want to override it?"
|
|
):
|
|
click.secho("Keeping existing provider configuration.", fg="yellow")
|
|
return
|
|
|
|
provider_models = get_provider_data()
|
|
if not provider_models:
|
|
return
|
|
|
|
while True:
|
|
selected_provider = select_provider(provider_models)
|
|
if selected_provider is None: # User typed 'q'
|
|
click.secho("Exiting...", fg="yellow")
|
|
sys.exit(0)
|
|
if selected_provider: # Valid selection
|
|
break
|
|
click.secho(
|
|
"No provider selected. Please try again or press 'q' to exit.", fg="red"
|
|
)
|
|
|
|
# Check if the selected provider has predefined models
|
|
if selected_provider in MODELS and MODELS[selected_provider]:
|
|
while True:
|
|
selected_model = select_model(selected_provider, provider_models)
|
|
if selected_model is None: # User typed 'q'
|
|
click.secho("Exiting...", fg="yellow")
|
|
sys.exit(0)
|
|
if selected_model: # Valid selection
|
|
break
|
|
click.secho(
|
|
"No model selected. Please try again or press 'q' to exit.",
|
|
fg="red",
|
|
)
|
|
env_vars["MODEL"] = selected_model
|
|
|
|
# Check if the selected provider requires API keys
|
|
if selected_provider in ENV_VARS:
|
|
provider_env_vars = ENV_VARS[selected_provider]
|
|
for details in provider_env_vars:
|
|
if details.get("default", False):
|
|
# Automatically add default key-value pairs
|
|
for key, value in details.items():
|
|
if key not in ["prompt", "key_name", "default"]:
|
|
env_vars[key] = value
|
|
elif "key_name" in details:
|
|
# Prompt for non-default key-value pairs
|
|
prompt = details["prompt"]
|
|
key_name = details["key_name"]
|
|
api_key_value = click.prompt(prompt, default="", show_default=False)
|
|
|
|
if api_key_value.strip():
|
|
env_vars[key_name] = api_key_value
|
|
|
|
if env_vars:
|
|
write_env_file(folder_path, env_vars)
|
|
click.secho("API keys and model saved to .env file", fg="green")
|
|
else:
|
|
click.secho(
|
|
"No API keys provided. Skipping .env file creation.", fg="yellow"
|
|
)
|
|
|
|
click.secho(f"Selected model: {env_vars.get('MODEL', 'N/A')}", fg="green")
|
|
|
|
package_dir = Path(__file__).parent
|
|
templates_dir = package_dir / "templates" / "crew"
|
|
|
|
root_template_files = (
|
|
[".gitignore", "pyproject.toml", "README.md", "knowledge/user_preference.txt"]
|
|
if not parent_folder
|
|
else []
|
|
)
|
|
tools_template_files = ["tools/custom_tool.py", "tools/__init__.py"]
|
|
config_template_files = ["config/agents.yaml", "config/tasks.yaml"]
|
|
src_template_files = (
|
|
["__init__.py", "main.py", "crew.py"] if not parent_folder else ["crew.py"]
|
|
)
|
|
|
|
for file_name in root_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = folder_path / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
src_folder = folder_path / "src" / folder_name if not parent_folder else folder_path
|
|
|
|
for file_name in src_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
if not parent_folder:
|
|
for file_name in tools_template_files + config_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)
|