refactor(core): dedup version, plus_api, and oauth2 stack into crewai-core

This commit is contained in:
Greyson Lalonde
2026-05-06 04:36:14 +08:00
parent 87b3df9803
commit 9fb76c3c7c
53 changed files with 1354 additions and 1917 deletions

View File

@@ -1,3 +1,7 @@
"""CLI authentication entry point."""
from __future__ import annotations
from crewai_cli.authentication.main import AuthenticationCommand from crewai_cli.authentication.main import AuthenticationCommand

View File

@@ -1 +1,8 @@
ALGORITHMS = ["RS256"] """Re-export of authentication constants from ``crewai_core.auth.constants``."""
from __future__ import annotations
from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS
__all__ = ["ALGORITHMS"]

View File

@@ -1,185 +1,30 @@
import time """CLI-side authentication wiring.
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
from crewai_core.token_manager import TokenManager Re-exports the OAuth2 primitives from ``crewai_core.auth`` and overrides the
import httpx ``_post_login`` hook to also log into the tool repository.
from pydantic import BaseModel, Field """
from rich.console import Console
from crewai_cli.authentication.utils import validate_jwt_token from __future__ import annotations
from crewai_cli.config import Settings
from crewai_core.auth.oauth2 import (
AuthenticationCommand as _BaseAuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
console,
)
from crewai_core.settings import Settings
console = Console() __all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"]
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
class Oauth2Settings(BaseModel): class AuthenticationCommand(_BaseAuthenticationCommand):
provider: str = Field( """CLI-side login that also signs the user into the tool repository."""
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
@classmethod def _post_login(self) -> None:
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings: self._login_to_tool_repository()
"""Create an Oauth2Settings instance from the CLI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai_cli.authentication.providers.base_provider import BaseProvider
class ProviderFactory:
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai_cli.authentication.providers.{settings.provider.lower()}"
)
# Converts from snake_case to CamelCase to obtain the provider class name.
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign up to CrewAI+"""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Get the device code to authenticate the user."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Display the authentication instructions to the user."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Polls the server for the token until it is received, or max attempts are reached."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._login_to_tool_repository()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validates the JWT token and saves the token to the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _login_to_tool_repository(self) -> None: def _login_to_tool_repository(self) -> None:
"""Login to the tool repository."""
from crewai_cli.tools.main import ToolCommand from crewai_cli.tools.main import ToolCommand
try: try:

View File

@@ -0,0 +1 @@
"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``."""

View File

@@ -1,34 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider """Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``."""
from __future__ import annotations
from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider
class Auth0Provider(BaseProvider): __all__ = ["Auth0Provider"]
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -1,33 +1,8 @@
from abc import ABC, abstractmethod """Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``."""
from crewai_cli.authentication.main import Oauth2Settings from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider
class BaseProvider(ABC): __all__ = ["BaseProvider"]
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str: ...
@abstractmethod
def get_token_url(self) -> str: ...
@abstractmethod
def get_jwks_url(self) -> str: ...
@abstractmethod
def get_issuer(self) -> str: ...
@abstractmethod
def get_audience(self) -> str: ...
@abstractmethod
def get_client_id(self) -> str: ...
def get_required_fields(self) -> list[str]:
"""Returns which provider-specific fields inside the "extra" dict will be required"""
return []
def get_oauth_scopes(self) -> list[str]:
return ["openid", "profile", "email"]

View File

@@ -1,43 +1,8 @@
from typing import cast """Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``."""
from crewai_cli.authentication.providers.base_provider import BaseProvider from __future__ import annotations
from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider
class EntraIdProvider(BaseProvider): __all__ = ["EntraIdProvider"]
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"

View File

@@ -1,32 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider """Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``."""
from __future__ import annotations
from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider
class KeycloakProvider(BaseProvider): __all__ = ["KeycloakProvider"]
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"

View File

@@ -1,42 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider """Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``."""
from __future__ import annotations
from crewai_core.auth.providers.okta import OktaProvider as OktaProvider
class OktaProvider(BaseProvider): __all__ = ["OktaProvider"]
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"

View File

@@ -1,30 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider """Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``."""
from __future__ import annotations
from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider
class WorkosProvider(BaseProvider): __all__ = ["WorkosProvider"]
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -1,13 +1,11 @@
from crewai_core.token_manager import TokenManager """Re-exports of authentication token helpers from ``crewai_core.auth.token``."""
from __future__ import annotations
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
class AuthError(Exception): __all__ = ["AuthError", "get_auth_token"]
pass
def get_auth_token() -> str:
"""Get the authentication token."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token

View File

@@ -1,63 +1,8 @@
from typing import Any """Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``."""
import jwt from __future__ import annotations
from jwt import PyJWKClient
from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
def validate_jwt_token( __all__ = ["validate_jwt_token"]
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""
Verify the token's signature and claims using PyJWT.
:param jwt_token: The JWT (JWS) string to validate.
:param jwks_url: The URL of the JWKS endpoint.
:param issuer: The expected issuer of the token.
:param audience: The expected audience of the token.
:return: The decoded token.
:raises Exception: If the token is invalid for any reason (e.g., signature mismatch,
expired, incorrect issuer/audience, JWKS fetching error,
missing required claims).
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e

View File

@@ -1,231 +1,12 @@
import os """Re-export of ``crewai_core.plus_api.PlusAPI``.
from typing import Any
from urllib.parse import urljoin
import httpx Kept as a stable import path for the CLI; new code should import from
``crewai_core.plus_api`` directly.
"""
from crewai_cli.config import Settings from __future__ import annotations
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_cli.version import get_crewai_version from crewai_core.plus_api import PlusAPI as PlusAPI
class PlusAPI: __all__ = ["PlusAPI"]
"""
This class exposes methods for working with the CrewAI+ API.
"""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)

View File

@@ -1,210 +1,24 @@
"""Version utilities for CrewAI CLI.""" """Re-exports of version utilities from ``crewai_core.version``.
from collections.abc import Mapping Kept as a stable import path for the CLI; new code should import from
from datetime import datetime, timedelta ``crewai_core.version`` directly.
from functools import lru_cache """
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
import appdirs from __future__ import annotations
from crewai_core.version import get_crewai_version as get_crewai_version
from packaging.version import InvalidVersion, Version, parse from crewai_core.version import (
check_version as check_version,
get_crewai_version as get_crewai_version,
get_latest_version_from_pypi as get_latest_version_from_pypi,
is_current_version_yanked as is_current_version_yanked,
is_newer_version_available as is_newer_version_available,
)
@lru_cache(maxsize=1) __all__ = [
def _get_cache_file() -> Path: "check_version",
"""Get the path to the version cache file. "get_crewai_version",
"get_latest_version_from_pypi",
Cached to avoid repeated filesystem operations. "is_current_version_yanked",
""" "is_newer_version_available",
cache_dir = Path(appdirs.user_cache_dir("crewai")) ]
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Check if the cache is still valid, less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Find the latest non-yanked version from PyPI releases data.
Args:
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
The latest non-yanked version string, or None if all versions are yanked.
"""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Check if a specific version is yanked.
Args:
version_str: The version string to check.
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Get the latest non-yanked version of CrewAI from PyPI.
Args:
timeout: Request timeout in seconds.
Returns:
Latest non-yanked version string or None if unable to fetch.
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Check if the currently installed version has been yanked on PyPI.
Reads from cache if available, otherwise triggers a fetch.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Check current and latest versions.
Returns:
Tuple of (current_version, latest_version).
latest_version is None if unable to fetch from PyPI.
"""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Check if a newer version is available.
Returns:
Tuple of (is_newer, current_version, latest_version).
"""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest

View File

@@ -43,7 +43,7 @@ class TestAuthenticationCommand:
"crewai_cli.authentication.main.AuthenticationCommand._display_auth_instructions" "crewai_cli.authentication.main.AuthenticationCommand._display_auth_instructions"
) )
@patch("crewai_cli.authentication.main.AuthenticationCommand._poll_for_token") @patch("crewai_cli.authentication.main.AuthenticationCommand._poll_for_token")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_login( def test_login(
self, self,
mock_console_print, mock_console_print,
@@ -82,8 +82,8 @@ class TestAuthenticationCommand:
self.auth_command.oauth2_provider._get_domain() == expected_urls["domain"] self.auth_command.oauth2_provider._get_domain() == expected_urls["domain"]
) )
@patch("crewai_cli.authentication.main.webbrowser") @patch("crewai_core.auth.oauth2.webbrowser")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_display_auth_instructions(self, mock_console_print, mock_webbrowser): def test_display_auth_instructions(self, mock_console_print, mock_webbrowser):
device_code_data = { device_code_data = {
"verification_uri_complete": "https://example.com/auth", "verification_uri_complete": "https://example.com/auth",
@@ -113,8 +113,8 @@ class TestAuthenticationCommand:
], ],
) )
@pytest.mark.parametrize("has_expiration", [True, False]) @pytest.mark.parametrize("has_expiration", [True, False])
@patch("crewai_cli.authentication.main.validate_jwt_token") @patch("crewai_core.auth.oauth2.validate_jwt_token")
@patch("crewai_cli.authentication.main.TokenManager.save_tokens") @patch("crewai_core.auth.oauth2.TokenManager.save_tokens")
def test_validate_and_save_token( def test_validate_and_save_token(
self, self,
mock_save_tokens, mock_save_tokens,
@@ -164,7 +164,7 @@ class TestAuthenticationCommand:
@patch("crewai_cli.tools.main.ToolCommand") @patch("crewai_cli.tools.main.ToolCommand")
@patch("crewai_cli.authentication.main.Settings") @patch("crewai_cli.authentication.main.Settings")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_login_to_tool_repository_success( def test_login_to_tool_repository_success(
self, mock_console_print, mock_settings, mock_tool_command self, mock_console_print, mock_settings, mock_tool_command
): ):
@@ -196,7 +196,7 @@ class TestAuthenticationCommand:
mock_console_print.assert_has_calls(expected_calls) mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.tools.main.ToolCommand") @patch("crewai_cli.tools.main.ToolCommand")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_login_to_tool_repository_error( def test_login_to_tool_repository_error(
self, mock_console_print, mock_tool_command self, mock_console_print, mock_tool_command
): ):
@@ -226,7 +226,7 @@ class TestAuthenticationCommand:
] ]
mock_console_print.assert_has_calls(expected_calls) mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.authentication.main.httpx.post") @patch("crewai_core.auth.oauth2.httpx.post")
def test_get_device_code(self, mock_post): def test_get_device_code(self, mock_post):
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
@@ -262,8 +262,8 @@ class TestAuthenticationCommand:
"verification_uri_complete": "https://example.com/auth", "verification_uri_complete": "https://example.com/auth",
} }
@patch("crewai_cli.authentication.main.httpx.post") @patch("crewai_core.auth.oauth2.httpx.post")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_poll_for_token_success(self, mock_console_print, mock_post): def test_poll_for_token_success(self, mock_console_print, mock_post):
mock_response_success = MagicMock() mock_response_success = MagicMock()
mock_response_success.status_code = 200 mock_response_success.status_code = 200
@@ -311,8 +311,8 @@ class TestAuthenticationCommand:
] ]
mock_console_print.assert_has_calls(expected_calls) mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.authentication.main.httpx.post") @patch("crewai_core.auth.oauth2.httpx.post")
@patch("crewai_cli.authentication.main.console.print") @patch("crewai_core.auth.oauth2.console.print")
def test_poll_for_token_timeout(self, mock_console_print, mock_post): def test_poll_for_token_timeout(self, mock_console_print, mock_post):
mock_response_pending = MagicMock() mock_response_pending = MagicMock()
mock_response_pending.status_code = 400 mock_response_pending.status_code = 400
@@ -330,7 +330,7 @@ class TestAuthenticationCommand:
"Timeout: Failed to get the token. Please try again.", style="bold red" "Timeout: Failed to get the token. Please try again.", style="bold red"
) )
@patch("crewai_cli.authentication.main.httpx.post") @patch("crewai_core.auth.oauth2.httpx.post")
def test_poll_for_token_error(self, mock_post): def test_poll_for_token_error(self, mock_post):
"""Test the method to poll for token (error path).""" """Test the method to poll for token (error path)."""
# Setup mock to return error # Setup mock to return error

View File

@@ -6,8 +6,8 @@ import jwt
from crewai_cli.authentication.utils import validate_jwt_token from crewai_cli.authentication.utils import validate_jwt_token
@patch("crewai_cli.authentication.utils.PyJWKClient", return_value=MagicMock()) @patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai_cli.authentication.utils.jwt") @patch("crewai_core.auth.utils.jwt")
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.return_value = {"exp": 1719859200} mock_jwt.decode.return_value = {"exp": 1719859200}

View File

@@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase):
self.assertIn("CrewAI-CLI/", self.api.headers["User-Agent"]) self.assertIn("CrewAI-CLI/", self.api.headers["User-Agent"])
self.assertTrue(self.api.headers["X-Crewai-Version"]) self.assertTrue(self.api.headers["X-Crewai-Version"])
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository(self, mock_make_request): def test_login_to_tool_repository(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -48,8 +48,8 @@ class TestPlusAPI(unittest.TestCase):
**kwargs, **kwargs,
) )
@patch("crewai_cli.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_login_to_tool_repository_with_org_uuid( def test_login_to_tool_repository_with_org_uuid(
self, mock_client_class, mock_settings_class self, mock_client_class, mock_settings_class
): ):
@@ -71,7 +71,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_tool(self, mock_make_request): def test_get_tool(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -82,8 +82,8 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid mock_settings.org_uuid = self.org_uuid
@@ -103,7 +103,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool(self, mock_make_request): def test_publish_tool(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -131,8 +131,8 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid mock_settings.org_uuid = self.org_uuid
@@ -170,7 +170,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_without_description(self, mock_make_request): def test_publish_tool_without_description(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -198,7 +198,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_make_request(self, mock_client_class): def test_make_request(self, mock_client_class):
mock_client_instance = MagicMock() mock_client_instance = MagicMock()
mock_response = MagicMock() mock_response = MagicMock()
@@ -213,35 +213,35 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_name(self, mock_make_request): def test_deploy_by_name(self, mock_make_request):
self.api.deploy_by_name("test_project") self.api.deploy_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy" "POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_uuid(self, mock_make_request): def test_deploy_by_uuid(self, mock_make_request):
self.api.deploy_by_uuid("test_uuid") self.api.deploy_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/test_uuid/deploy" "POST", "/crewai_plus/api/v1/crews/test_uuid/deploy"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_name(self, mock_make_request): def test_crew_status_by_name(self, mock_make_request):
self.api.crew_status_by_name("test_project") self.api.crew_status_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/status" "GET", "/crewai_plus/api/v1/crews/by-name/test_project/status"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_uuid(self, mock_make_request): def test_crew_status_by_uuid(self, mock_make_request):
self.api.crew_status_by_uuid("test_uuid") self.api.crew_status_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/test_uuid/status" "GET", "/crewai_plus/api/v1/crews/test_uuid/status"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_name(self, mock_make_request): def test_crew_by_name(self, mock_make_request):
self.api.crew_by_name("test_project") self.api.crew_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
@@ -253,7 +253,7 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log" "GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_uuid(self, mock_make_request): def test_crew_by_uuid(self, mock_make_request):
self.api.crew_by_uuid("test_uuid") self.api.crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
@@ -265,26 +265,26 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log" "GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_name(self, mock_make_request): def test_delete_crew_by_name(self, mock_make_request):
self.api.delete_crew_by_name("test_project") self.api.delete_crew_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/by-name/test_project" "DELETE", "/crewai_plus/api/v1/crews/by-name/test_project"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_uuid(self, mock_make_request): def test_delete_crew_by_uuid(self, mock_make_request):
self.api.delete_crew_by_uuid("test_uuid") self.api.delete_crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/test_uuid" "DELETE", "/crewai_plus/api/v1/crews/test_uuid"
) )
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_list_crews(self, mock_make_request): def test_list_crews(self, mock_make_request):
self.api.list_crews() self.api.list_crews()
mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews") mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews")
@patch("crewai_cli.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_create_crew(self, mock_make_request): def test_create_crew(self, mock_make_request):
payload = {"name": "test_crew"} payload = {"name": "test_crew"}
self.api.create_crew(payload) self.api.create_crew(payload)
@@ -292,7 +292,7 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload "POST", "/crewai_plus/api/v1/crews", json=payload
) )
@patch("crewai_cli.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""}) @patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class): def test_custom_base_url(self, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -333,7 +333,7 @@ async def test_get_agent(mock_async_client_class):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("httpx.AsyncClient") @patch("httpx.AsyncClient")
@patch("crewai_cli.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class): async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid" org_uuid = "test-org-uuid"
mock_settings = MagicMock() mock_settings = MagicMock()

View File

@@ -7,15 +7,17 @@ from unittest.mock import MagicMock, patch
from crewai_cli.version import get_crewai_version as _get_ver from crewai_cli.version import get_crewai_version as _get_ver
from crewai_cli.version import ( from crewai_cli.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
get_crewai_version, get_crewai_version,
get_latest_version_from_pypi, get_latest_version_from_pypi,
is_current_version_yanked, is_current_version_yanked,
is_newer_version_available, is_newer_version_available,
) )
from crewai_core.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
)
def test_dynamic_versioning_consistency() -> None: def test_dynamic_versioning_consistency() -> None:
@@ -60,8 +62,8 @@ class TestVersionChecking:
cache_data = {"version": "1.0.0"} cache_data = {"version": "1.0.0"}
assert _is_cache_valid(cache_data) is False assert _is_cache_valid(cache_data) is False
@patch("crewai_cli.version.Path.exists") @patch("crewai_core.version.Path.exists")
@patch("crewai_cli.version.request.urlopen") @patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_success( def test_get_latest_version_from_pypi_success(
self, mock_urlopen: MagicMock, mock_exists: MagicMock self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None: ) -> None:
@@ -82,8 +84,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi() version = get_latest_version_from_pypi()
assert version == "2.0.0" assert version == "2.0.0"
@patch("crewai_cli.version.Path.exists") @patch("crewai_core.version.Path.exists")
@patch("crewai_cli.version.request.urlopen") @patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_failure( def test_get_latest_version_from_pypi_failure(
self, mock_urlopen: MagicMock, mock_exists: MagicMock self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None: ) -> None:
@@ -97,8 +99,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi() version = get_latest_version_from_pypi()
assert version is None assert version is None
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_true( def test_is_newer_version_available_true(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -111,8 +113,8 @@ class TestVersionChecking:
assert current == "1.0.0" assert current == "1.0.0"
assert latest == "2.0.0" assert latest == "2.0.0"
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_false( def test_is_newer_version_available_false(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -125,8 +127,8 @@ class TestVersionChecking:
assert current == "2.0.0" assert current == "2.0.0"
assert latest == "2.0.0" assert latest == "2.0.0"
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_with_none_latest( def test_is_newer_version_available_with_none_latest(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -260,8 +262,8 @@ class TestIsVersionYanked:
class TestIsCurrentVersionYanked: class TestIsCurrentVersionYanked:
"""Test is_current_version_yanked public function.""" """Test is_current_version_yanked public function."""
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_reads_from_valid_cache( def test_reads_from_valid_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None: ) -> None:
@@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked:
assert is_yanked is True assert is_yanked is True
assert reason == "bad release" assert reason == "bad release"
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_not_yanked_from_cache( def test_not_yanked_from_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None: ) -> None:
@@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False assert is_yanked is False
assert reason == "" assert reason == ""
@patch("crewai_cli.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_triggers_fetch_on_stale_cache( def test_triggers_fetch_on_stale_cache(
self, self,
mock_cache_file: MagicMock, mock_cache_file: MagicMock,
@@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False assert is_yanked is False
mock_fetch.assert_called_once() mock_fetch.assert_called_once()
@patch("crewai_cli.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_cli.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_returns_false_on_fetch_failure( def test_returns_false_on_fetch_failure(
self, self,
mock_cache_file: MagicMock, mock_cache_file: MagicMock,

View File

@@ -10,7 +10,10 @@ requires-python = ">=3.10, <3.14"
dependencies = [ dependencies = [
"appdirs~=1.4.4", "appdirs~=1.4.4",
"cryptography>=42.0", "cryptography>=42.0",
"httpx~=0.28.1",
"packaging>=23.0",
"portalocker~=2.7.0", "portalocker~=2.7.0",
"pyjwt>=2.9.0,<3",
"pydantic>=2.11.9,<2.13", "pydantic>=2.11.9,<2.13",
"rich>=13.7.1", "rich>=13.7.1",
"opentelemetry-api~=1.34.0", "opentelemetry-api~=1.34.0",

View File

@@ -0,0 +1,24 @@
"""OAuth2 authentication primitives — shared by crewai and crewai-cli."""
from __future__ import annotations
from crewai_core.auth.oauth2 import (
AuthenticationCommand as AuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
)
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
__all__ = [
"AuthError",
"AuthenticationCommand",
"Oauth2Settings",
"ProviderFactory",
"get_auth_token",
"validate_jwt_token",
]

View File

@@ -0,0 +1,8 @@
"""Authentication constants."""
from __future__ import annotations
from typing import Final
ALGORITHMS: Final[list[str]] = ["RS256"]

View File

@@ -0,0 +1,186 @@
"""OAuth2 device-flow authentication for the CrewAI platform."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
import httpx
from pydantic import BaseModel, Field
from rich.console import Console
from crewai_core.auth.utils import validate_jwt_token
from crewai_core.settings import Settings
from crewai_core.token_manager import TokenManager
console = Console()
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
class Oauth2Settings(BaseModel):
"""OAuth2 provider configuration."""
provider: str = Field(
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
@classmethod
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
"""Build an ``Oauth2Settings`` instance from the persisted CrewAI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai_core.auth.providers.base_provider import BaseProvider
class ProviderFactory:
"""Factory for resolving the configured OAuth2 provider."""
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
"""Create a provider instance from settings, importing the module dynamically."""
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai_core.auth.providers.{settings.provider.lower()}"
)
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
"""Drives the OAuth2 device-flow login against the configured provider."""
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign in to the CrewAI platform via the OAuth2 device flow."""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Request a device code from the provider."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Print and open the verification URL the user must visit."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Poll the token endpoint until authentication completes or times out."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._post_login()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validate the JWT and persist it via the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _post_login(self) -> None:
"""Hook called after a successful login. Override to extend behavior."""

View File

@@ -0,0 +1 @@
"""OAuth2 authentication providers."""

View File

@@ -0,0 +1,40 @@
"""Auth0 OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class Auth0Provider(BaseProvider):
"""Auth0 OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -0,0 +1,46 @@
"""Base OAuth2 provider interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from crewai_core.auth.oauth2 import Oauth2Settings
class BaseProvider(ABC):
"""Abstract base class for OAuth2 providers."""
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str:
"""Return the authorization endpoint URL."""
@abstractmethod
def get_token_url(self) -> str:
"""Return the token endpoint URL."""
@abstractmethod
def get_jwks_url(self) -> str:
"""Return the JWKS endpoint URL."""
@abstractmethod
def get_issuer(self) -> str:
"""Return the OAuth issuer identifier."""
@abstractmethod
def get_audience(self) -> str:
"""Return the OAuth audience identifier."""
@abstractmethod
def get_client_id(self) -> str:
"""Return the OAuth client identifier."""
def get_required_fields(self) -> list[str]:
"""Return provider-specific keys required inside ``Oauth2Settings.extra``."""
return []
def get_oauth_scopes(self) -> list[str]:
"""Return the OAuth scopes to request."""
return ["openid", "profile", "email"]

View File

@@ -0,0 +1,49 @@
"""Entra ID (Azure AD) OAuth2 provider."""
from __future__ import annotations
from typing import cast
from crewai_core.auth.providers.base_provider import BaseProvider
class EntraIdProvider(BaseProvider):
"""Entra ID (Azure AD) OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"

View File

@@ -0,0 +1,38 @@
"""Keycloak OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class KeycloakProvider(BaseProvider):
"""Keycloak OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"

View File

@@ -0,0 +1,48 @@
"""Okta OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class OktaProvider(BaseProvider):
"""Okta OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"

View File

@@ -0,0 +1,36 @@
"""WorkOS OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class WorkosProvider(BaseProvider):
"""WorkOS OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -0,0 +1,17 @@
"""Authentication token retrieval."""
from __future__ import annotations
from crewai_core.token_manager import TokenManager
class AuthError(Exception):
"""Raised when authentication fails."""
def get_auth_token() -> str:
"""Return the saved authentication token; raise ``AuthError`` if missing."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token

View File

@@ -0,0 +1,71 @@
"""JWT token validation utilities."""
from __future__ import annotations
from typing import Any
import jwt
from jwt import PyJWKClient
from crewai_core.auth.constants import ALGORITHMS
def validate_jwt_token(
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""Verify a JWT's signature and claims using PyJWT.
Args:
jwt_token: The JWT (JWS) string to validate.
jwks_url: The URL of the JWKS endpoint.
issuer: The expected issuer of the token.
audience: The expected audience of the token.
Returns:
The decoded token.
Raises:
Exception: If the token is invalid for any reason.
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=ALGORITHMS,
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e

View File

@@ -44,7 +44,7 @@ def _redis_available() -> bool:
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _redis_connection() -> redis.Redis: def _redis_connection() -> redis.Redis[bytes]:
"""Return a cached Redis connection, creating one on first call.""" """Return a cached Redis connection, creating one on first call."""
from redis import Redis from redis import Redis

View File

@@ -0,0 +1,232 @@
"""CrewAI+ API client — shared by both crewai and crewai-cli."""
from __future__ import annotations
import os
from typing import Any
from urllib.parse import urljoin
import httpx
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_core.settings import Settings
from crewai_core.version import get_crewai_version
class PlusAPI:
"""Client for working with the CrewAI+ API."""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)

View File

@@ -1,9 +1,24 @@
"""Version utilities for CrewAI.""" """Version utilities — installed version + PyPI freshness/yank checks.
Shared by both ``crewai`` and ``crewai-cli`` so the PyPI-checking logic lives
in one place. Frontends (``crewai version`` CLI, banner printer) consume the
helpers here without re-implementing them.
"""
from __future__ import annotations from __future__ import annotations
from functools import cache from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import cache, lru_cache
import importlib.metadata import importlib.metadata
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
import appdirs
from packaging.version import InvalidVersion, Version, parse
@cache @cache
@@ -21,3 +36,159 @@ def get_crewai_version() -> str:
return importlib.metadata.version("crewai-core") return importlib.metadata.version("crewai-core")
except importlib.metadata.PackageNotFoundError: except importlib.metadata.PackageNotFoundError:
return "unknown" return "unknown"
@lru_cache(maxsize=1)
def _get_cache_file() -> Path:
"""Return the path to the version cache file, creating the dir if needed."""
cache_dir = Path(appdirs.user_cache_dir("crewai"))
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Return True if the cache is less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Return the latest non-prerelease, non-yanked version from PyPI releases."""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Return ``(yanked, reason)`` for ``version_str`` against PyPI releases."""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Return the latest non-yanked PyPI version of CrewAI, or ``None`` on failure."""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Return ``(yanked, reason)`` for the currently installed version."""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Return ``(current_version, latest_version)``; latest is ``None`` on fetch failure."""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Return ``(is_newer, current_version, latest_version)``."""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest

View File

@@ -1,7 +1,22 @@
"""Authentication utilities for the CrewAI platform.""" """Authentication utilities — re-exported from ``crewai_core.auth``."""
from crewai.auth.oauth2 import AuthenticationCommand from __future__ import annotations
from crewai.auth.token import AuthError, get_auth_token
from crewai_core.auth import (
AuthError as AuthError,
AuthenticationCommand as AuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
get_auth_token as get_auth_token,
validate_jwt_token as validate_jwt_token,
)
__all__ = ["AuthError", "AuthenticationCommand", "get_auth_token"] __all__ = [
"AuthError",
"AuthenticationCommand",
"Oauth2Settings",
"ProviderFactory",
"get_auth_token",
"validate_jwt_token",
]

View File

@@ -1,3 +1,8 @@
"""Authentication constants.""" """Re-export of authentication constants from ``crewai_core.auth.constants``."""
ALGORITHMS = ["RS256"] from __future__ import annotations
from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS
__all__ = ["ALGORITHMS"]

View File

@@ -1,184 +1,12 @@
"""OAuth2 authentication for the CrewAI platform.""" """Re-exports of OAuth2 primitives from ``crewai_core.auth.oauth2``."""
import time from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
from crewai_core.settings import Settings from crewai_core.auth.oauth2 import (
from crewai_core.token_manager import TokenManager AuthenticationCommand as AuthenticationCommand,
import httpx Oauth2Settings as Oauth2Settings,
from pydantic import BaseModel, Field ProviderFactory as ProviderFactory,
from rich.console import Console )
from crewai.auth.utils import validate_jwt_token
console = Console() __all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"]
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
class Oauth2Settings(BaseModel):
"""OAuth2 provider configuration."""
provider: str = Field(
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
@classmethod
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
"""Create an Oauth2Settings instance from the CLI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai.auth.providers.base_provider import BaseProvider
class ProviderFactory:
"""Factory for creating OAuth2 providers from settings."""
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
"""Create a provider instance from settings."""
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai.auth.providers.{settings.provider.lower()}"
)
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
"""Handles authentication with the CrewAI platform."""
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign up to CrewAI+"""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Get the device code to authenticate the user."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Display the authentication instructions to the user."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Polls the server for the token until it is received, or max attempts are reached."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._post_login()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validates the JWT token and saves the token to the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _post_login(self) -> None:
"""Hook called after successful login. Override in subclasses for additional behavior."""

View File

@@ -1 +1 @@
"""OAuth2 authentication providers.""" """OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``."""

View File

@@ -1,38 +1,8 @@
"""Auth0 OAuth2 provider.""" """Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``."""
from crewai.auth.providers.base_provider import BaseProvider from __future__ import annotations
from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider
class Auth0Provider(BaseProvider): __all__ = ["Auth0Provider"]
"""Auth0 OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -1,44 +1,8 @@
"""Base OAuth2 provider interface.""" """Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``."""
from abc import ABC, abstractmethod from __future__ import annotations
from crewai.auth.oauth2 import Oauth2Settings from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider
class BaseProvider(ABC): __all__ = ["BaseProvider"]
"""Abstract base class for OAuth2 providers."""
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str:
"""Return the authorization endpoint URL."""
@abstractmethod
def get_token_url(self) -> str:
"""Return the token endpoint URL."""
@abstractmethod
def get_jwks_url(self) -> str:
"""Return the JWKS endpoint URL."""
@abstractmethod
def get_issuer(self) -> str:
"""Return the OAuth issuer identifier."""
@abstractmethod
def get_audience(self) -> str:
"""Return the OAuth audience identifier."""
@abstractmethod
def get_client_id(self) -> str:
"""Return the OAuth client identifier."""
def get_required_fields(self) -> list[str]:
"""Returns which provider-specific fields inside the "extra" dict will be required."""
return []
def get_oauth_scopes(self) -> list[str]:
"""Returns the OAuth scopes to request."""
return ["openid", "profile", "email"]

View File

@@ -1,47 +1,8 @@
"""Entra ID (Azure AD) OAuth2 provider.""" """Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``."""
from typing import cast from __future__ import annotations
from crewai.auth.providers.base_provider import BaseProvider from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider
class EntraIdProvider(BaseProvider): __all__ = ["EntraIdProvider"]
"""Entra ID (Azure AD) OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"

View File

@@ -1,36 +1,8 @@
"""Keycloak OAuth2 provider.""" """Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``."""
from crewai.auth.providers.base_provider import BaseProvider from __future__ import annotations
from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider
class KeycloakProvider(BaseProvider): __all__ = ["KeycloakProvider"]
"""Keycloak OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"

View File

@@ -1,46 +1,8 @@
"""Okta OAuth2 provider.""" """Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``."""
from crewai.auth.providers.base_provider import BaseProvider from __future__ import annotations
from crewai_core.auth.providers.okta import OktaProvider as OktaProvider
class OktaProvider(BaseProvider): __all__ = ["OktaProvider"]
"""Okta OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"

View File

@@ -1,34 +1,8 @@
"""WorkOS OAuth2 provider.""" """Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``."""
from crewai.auth.providers.base_provider import BaseProvider from __future__ import annotations
from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider
class WorkosProvider(BaseProvider): __all__ = ["WorkosProvider"]
"""WorkOS OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -1,15 +1,11 @@
"""Authentication token retrieval.""" """Re-exports of authentication token helpers from ``crewai_core.auth.token``."""
from crewai_core.token_manager import TokenManager from __future__ import annotations
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
class AuthError(Exception): __all__ = ["AuthError", "get_auth_token"]
"""Raised when authentication fails."""
def get_auth_token() -> str:
"""Get the authentication token."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token

View File

@@ -1,67 +1,8 @@
"""JWT token validation utilities.""" """Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``."""
from typing import Any from __future__ import annotations
import jwt from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
from jwt import PyJWKClient
def validate_jwt_token( __all__ = ["validate_jwt_token"]
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""Verify the token's signature and claims using PyJWT.
Args:
jwt_token: The JWT (JWS) string to validate.
jwks_url: The URL of the JWKS endpoint.
issuer: The expected issuer of the token.
audience: The expected audience of the token.
Returns:
The decoded token.
Raises:
Exception: If the token is invalid for any reason.
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e

View File

@@ -1,230 +1,12 @@
"""CrewAI+ API client.""" """Re-export of ``crewai_core.plus_api.PlusAPI``.
import os Kept as a stable import path for the framework; new code should import from
from typing import Any ``crewai_core.plus_api`` directly.
from urllib.parse import urljoin """
from crewai_core.settings import Settings from __future__ import annotations
import httpx
from crewai.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_core.plus_api import PlusAPI as PlusAPI
from crewai.version import get_crewai_version
class PlusAPI: __all__ = ["PlusAPI"]
"""Client for working with the CrewAI+ API."""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)

View File

@@ -1,215 +1,24 @@
"""Version utilities for CrewAI.""" """Re-exports of version utilities from ``crewai_core.version``.
from collections.abc import Mapping Kept as a stable import path for the framework; new code should import from
from datetime import datetime, timedelta ``crewai_core.version`` directly.
from functools import lru_cache """
import importlib.metadata
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
import appdirs from __future__ import annotations
from packaging.version import InvalidVersion, Version, parse
from crewai_core.version import (
check_version as check_version,
get_crewai_version as get_crewai_version,
get_latest_version_from_pypi as get_latest_version_from_pypi,
is_current_version_yanked as is_current_version_yanked,
is_newer_version_available as is_newer_version_available,
)
@lru_cache(maxsize=1) __all__ = [
def _get_cache_file() -> Path: "check_version",
"""Get the path to the version cache file. "get_crewai_version",
"get_latest_version_from_pypi",
Cached to avoid repeated filesystem operations. "is_current_version_yanked",
""" "is_newer_version_available",
cache_dir = Path(appdirs.user_cache_dir("crewai")) ]
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def get_crewai_version() -> str:
"""Get the version number of the installed CrewAI package."""
return importlib.metadata.version("crewai")
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Check if the cache is still valid, less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Find the latest non-yanked version from PyPI releases data.
Args:
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
The latest non-yanked version string, or None if all versions are yanked.
"""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Check if a specific version is yanked.
Args:
version_str: The version string to check.
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Get the latest non-yanked version of CrewAI from PyPI.
Args:
timeout: Request timeout in seconds.
Returns:
Latest non-yanked version string or None if unable to fetch.
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Check if the currently installed version has been yanked on PyPI.
Reads from cache if available, otherwise triggers a fetch.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Check current and latest versions.
Returns:
Tuple of (current_version, latest_version).
latest_version is None if unable to fetch from PyPI.
"""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Check if a newer version is available.
Returns:
Tuple of (is_newer, current_version, latest_version).
"""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest

View File

@@ -6,8 +6,8 @@ import jwt
from crewai.auth.utils import validate_jwt_token from crewai.auth.utils import validate_jwt_token
@patch("crewai.auth.utils.PyJWKClient", return_value=MagicMock()) @patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai.auth.utils.jwt") @patch("crewai_core.auth.utils.jwt")
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.return_value = {"exp": 1719859200} mock_jwt.decode.return_value = {"exp": 1719859200}

View File

@@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase):
self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"]) self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"])
self.assertTrue(self.api.headers["X-Crewai-Version"]) self.assertTrue(self.api.headers["X-Crewai-Version"])
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository(self, mock_make_request): def test_login_to_tool_repository(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -32,7 +32,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository_with_user_identifier(self, mock_make_request): def test_login_to_tool_repository_with_user_identifier(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -60,8 +60,8 @@ class TestPlusAPI(unittest.TestCase):
**kwargs, **kwargs,
) )
@patch("crewai.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_login_to_tool_repository_with_org_uuid( def test_login_to_tool_repository_with_org_uuid(
self, mock_client_class, mock_settings_class self, mock_client_class, mock_settings_class
): ):
@@ -83,7 +83,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_tool(self, mock_make_request): def test_get_tool(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -94,8 +94,8 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid mock_settings.org_uuid = self.org_uuid
@@ -115,7 +115,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool(self, mock_make_request): def test_publish_tool(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -143,8 +143,8 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid mock_settings.org_uuid = self.org_uuid
@@ -182,7 +182,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_without_description(self, mock_make_request): def test_publish_tool_without_description(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -210,7 +210,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_with_tools_metadata(self, mock_make_request): def test_publish_tool_with_tools_metadata(self, mock_make_request):
mock_response = MagicMock() mock_response = MagicMock()
mock_make_request.return_value = mock_response mock_make_request.return_value = mock_response
@@ -251,7 +251,7 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.httpx.Client") @patch("crewai_core.plus_api.httpx.Client")
def test_make_request(self, mock_client_class): def test_make_request(self, mock_client_class):
mock_client_instance = MagicMock() mock_client_instance = MagicMock()
mock_response = MagicMock() mock_response = MagicMock()
@@ -266,35 +266,35 @@ class TestPlusAPI(unittest.TestCase):
) )
self.assertEqual(response, mock_response) self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_name(self, mock_make_request): def test_deploy_by_name(self, mock_make_request):
self.api.deploy_by_name("test_project") self.api.deploy_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy" "POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_uuid(self, mock_make_request): def test_deploy_by_uuid(self, mock_make_request):
self.api.deploy_by_uuid("test_uuid") self.api.deploy_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/test_uuid/deploy" "POST", "/crewai_plus/api/v1/crews/test_uuid/deploy"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_name(self, mock_make_request): def test_crew_status_by_name(self, mock_make_request):
self.api.crew_status_by_name("test_project") self.api.crew_status_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/status" "GET", "/crewai_plus/api/v1/crews/by-name/test_project/status"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_uuid(self, mock_make_request): def test_crew_status_by_uuid(self, mock_make_request):
self.api.crew_status_by_uuid("test_uuid") self.api.crew_status_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/test_uuid/status" "GET", "/crewai_plus/api/v1/crews/test_uuid/status"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_name(self, mock_make_request): def test_crew_by_name(self, mock_make_request):
self.api.crew_by_name("test_project") self.api.crew_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
@@ -306,7 +306,7 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log" "GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_uuid(self, mock_make_request): def test_crew_by_uuid(self, mock_make_request):
self.api.crew_by_uuid("test_uuid") self.api.crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
@@ -318,26 +318,26 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log" "GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_name(self, mock_make_request): def test_delete_crew_by_name(self, mock_make_request):
self.api.delete_crew_by_name("test_project") self.api.delete_crew_by_name("test_project")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/by-name/test_project" "DELETE", "/crewai_plus/api/v1/crews/by-name/test_project"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_uuid(self, mock_make_request): def test_delete_crew_by_uuid(self, mock_make_request):
self.api.delete_crew_by_uuid("test_uuid") self.api.delete_crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with( mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/test_uuid" "DELETE", "/crewai_plus/api/v1/crews/test_uuid"
) )
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_list_crews(self, mock_make_request): def test_list_crews(self, mock_make_request):
self.api.list_crews() self.api.list_crews()
mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews") mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews")
@patch("crewai.plus_api.PlusAPI._make_request") @patch("crewai_core.plus_api.PlusAPI._make_request")
def test_create_crew(self, mock_make_request): def test_create_crew(self, mock_make_request):
payload = {"name": "test_crew"} payload = {"name": "test_crew"}
self.api.create_crew(payload) self.api.create_crew(payload)
@@ -345,7 +345,7 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload "POST", "/crewai_plus/api/v1/crews", json=payload
) )
@patch("crewai.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""}) @patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class): def test_custom_base_url(self, mock_settings_class):
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -386,7 +386,7 @@ async def test_get_agent(mock_async_client_class):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("httpx.AsyncClient") @patch("httpx.AsyncClient")
@patch("crewai.plus_api.Settings") @patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class): async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid" org_uuid = "test-org-uuid"
mock_settings = MagicMock() mock_settings = MagicMock()

View File

@@ -7,15 +7,17 @@ from unittest.mock import MagicMock, patch
from crewai import __version__ from crewai import __version__
from crewai.version import ( from crewai.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
get_crewai_version, get_crewai_version,
get_latest_version_from_pypi, get_latest_version_from_pypi,
is_current_version_yanked, is_current_version_yanked,
is_newer_version_available, is_newer_version_available,
) )
from crewai_core.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
)
def test_dynamic_versioning_consistency() -> None: def test_dynamic_versioning_consistency() -> None:
@@ -60,8 +62,8 @@ class TestVersionChecking:
cache_data = {"version": "1.0.0"} cache_data = {"version": "1.0.0"}
assert _is_cache_valid(cache_data) is False assert _is_cache_valid(cache_data) is False
@patch("crewai.version.Path.exists") @patch("crewai_core.version.Path.exists")
@patch("crewai.version.request.urlopen") @patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_success( def test_get_latest_version_from_pypi_success(
self, mock_urlopen: MagicMock, mock_exists: MagicMock self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None: ) -> None:
@@ -82,8 +84,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi() version = get_latest_version_from_pypi()
assert version == "2.0.0" assert version == "2.0.0"
@patch("crewai.version.Path.exists") @patch("crewai_core.version.Path.exists")
@patch("crewai.version.request.urlopen") @patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_failure( def test_get_latest_version_from_pypi_failure(
self, mock_urlopen: MagicMock, mock_exists: MagicMock self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None: ) -> None:
@@ -97,8 +99,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi() version = get_latest_version_from_pypi()
assert version is None assert version is None
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_true( def test_is_newer_version_available_true(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -111,8 +113,8 @@ class TestVersionChecking:
assert current == "1.0.0" assert current == "1.0.0"
assert latest == "2.0.0" assert latest == "2.0.0"
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_false( def test_is_newer_version_available_false(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -125,8 +127,8 @@ class TestVersionChecking:
assert current == "2.0.0" assert current == "2.0.0"
assert latest == "2.0.0" assert latest == "2.0.0"
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_with_none_latest( def test_is_newer_version_available_with_none_latest(
self, mock_latest: MagicMock, mock_current: MagicMock self, mock_latest: MagicMock, mock_current: MagicMock
) -> None: ) -> None:
@@ -260,8 +262,8 @@ class TestIsVersionYanked:
class TestIsCurrentVersionYanked: class TestIsCurrentVersionYanked:
"""Test is_current_version_yanked public function.""" """Test is_current_version_yanked public function."""
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_reads_from_valid_cache( def test_reads_from_valid_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None: ) -> None:
@@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked:
assert is_yanked is True assert is_yanked is True
assert reason == "bad release" assert reason == "bad release"
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_not_yanked_from_cache( def test_not_yanked_from_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None: ) -> None:
@@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False assert is_yanked is False
assert reason == "" assert reason == ""
@patch("crewai.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_triggers_fetch_on_stale_cache( def test_triggers_fetch_on_stale_cache(
self, self,
mock_cache_file: MagicMock, mock_cache_file: MagicMock,
@@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False assert is_yanked is False
mock_fetch.assert_called_once() mock_fetch.assert_called_once()
@patch("crewai.version.get_latest_version_from_pypi") @patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai.version.get_crewai_version") @patch("crewai_core.version.get_crewai_version")
@patch("crewai.version._get_cache_file") @patch("crewai_core.version._get_cache_file")
def test_returns_false_on_fetch_failure( def test_returns_false_on_fetch_failure(
self, self,
mock_cache_file: MagicMock, mock_cache_file: MagicMock,

View File

@@ -29,6 +29,7 @@ dev = [
"types-psycopg2==2.9.21.20251012", "types-psycopg2==2.9.21.20251012",
"types-pymysql==1.1.0.20250916", "types-pymysql==1.1.0.20250916",
"types-aiofiles~=25.1.0", "types-aiofiles~=25.1.0",
"types-redis~=4.6",
"commitizen>=4.13.9", "commitizen>=4.13.9",
"pip-audit==2.9.0", "pip-audit==2.9.0",
] ]

54
uv.lock generated
View File

@@ -63,6 +63,7 @@ dev = [
{ name = "types-psycopg2", specifier = "==2.9.21.20251012" }, { name = "types-psycopg2", specifier = "==2.9.21.20251012" },
{ name = "types-pymysql", specifier = "==1.1.0.20250916" }, { name = "types-pymysql", specifier = "==1.1.0.20250916" },
{ name = "types-pyyaml", specifier = "==6.0.*" }, { name = "types-pyyaml", specifier = "==6.0.*" },
{ name = "types-redis", specifier = "~=4.6" },
{ name = "types-regex", specifier = "==2026.1.15.*" }, { name = "types-regex", specifier = "==2026.1.15.*" },
{ name = "types-requests", specifier = "~=2.31.0.6" }, { name = "types-requests", specifier = "~=2.31.0.6" },
{ name = "vcrpy", specifier = "==7.0.0" }, { name = "vcrpy", specifier = "==7.0.0" },
@@ -1473,11 +1474,14 @@ source = { editable = "lib/crewai-core" }
dependencies = [ dependencies = [
{ name = "appdirs" }, { name = "appdirs" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "httpx" },
{ name = "opentelemetry-api" }, { name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" }, { name = "opentelemetry-sdk" },
{ name = "packaging" },
{ name = "portalocker" }, { name = "portalocker" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt" },
{ name = "rich" }, { name = "rich" },
{ name = "tomli" }, { name = "tomli" },
] ]
@@ -1486,11 +1490,14 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" }, { name = "appdirs", specifier = "~=1.4.4" },
{ name = "cryptography", specifier = ">=42.0" }, { name = "cryptography", specifier = ">=42.0" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" }, { name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" }, { name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "packaging", specifier = ">=23.0" },
{ name = "portalocker", specifier = "~=2.7.0" }, { name = "portalocker", specifier = "~=2.7.0" },
{ name = "pydantic", specifier = ">=2.11.9,<2.13" }, { name = "pydantic", specifier = ">=2.11.9,<2.13" },
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
{ name = "rich", specifier = ">=13.7.1" }, { name = "rich", specifier = ">=13.7.1" },
{ name = "tomli", specifier = "~=2.0.2" }, { name = "tomli", specifier = "~=2.0.2" },
] ]
@@ -9042,6 +9049,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" }, { url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" },
] ]
[[package]]
name = "types-cffi"
version = "2.0.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" },
]
[[package]] [[package]]
name = "types-psycopg2" name = "types-psycopg2"
version = "2.9.21.20251012" version = "2.9.21.20251012"
@@ -9060,6 +9079,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" }, { url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" },
] ]
[[package]]
name = "types-pyopenssl"
version = "24.1.0.20240722"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" },
]
[[package]] [[package]]
name = "types-pyyaml" name = "types-pyyaml"
version = "6.0.12.20260408" version = "6.0.12.20260408"
@@ -9069,6 +9101,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" },
] ]
[[package]]
name = "types-redis"
version = "4.6.0.20241004"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-pyopenssl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" },
]
[[package]] [[package]]
name = "types-regex" name = "types-regex"
version = "2026.1.15.20260116" version = "2026.1.15.20260116"
@@ -9099,6 +9144,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" },
] ]
[[package]]
name = "types-setuptools"
version = "82.0.0.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"