From 9fb76c3c7cda1ce3853955bb3df45deaf7730e3f Mon Sep 17 00:00:00 2001 From: Greyson Lalonde Date: Wed, 6 May 2026 04:36:14 +0800 Subject: [PATCH] refactor(core): dedup version, plus_api, and oauth2 stack into crewai-core --- .../src/crewai_cli/authentication/__init__.py | 4 + .../crewai_cli/authentication/constants.py | 9 +- lib/cli/src/crewai_cli/authentication/main.py | 191 ++------------ .../authentication/providers/__init__.py | 1 + .../authentication/providers/auth0.py | 38 +-- .../authentication/providers/base_provider.py | 35 +-- .../authentication/providers/entra_id.py | 45 +--- .../authentication/providers/keycloak.py | 36 +-- .../authentication/providers/okta.py | 46 +--- .../authentication/providers/workos.py | 34 +-- .../src/crewai_cli/authentication/token.py | 20 +- .../src/crewai_cli/authentication/utils.py | 65 +---- lib/cli/src/crewai_cli/plus_api.py | 235 +----------------- lib/cli/src/crewai_cli/version.py | 226 ++--------------- .../tests/authentication/test_auth_main.py | 26 +- lib/cli/tests/authentication/test_utils.py | 4 +- lib/cli/tests/test_plus_api.py | 46 ++-- lib/cli/tests/test_version.py | 50 ++-- lib/crewai-core/pyproject.toml | 3 + .../src/crewai_core/auth/__init__.py | 24 ++ .../src/crewai_core/auth/constants.py | 8 + .../src/crewai_core/auth/oauth2.py | 186 ++++++++++++++ .../crewai_core/auth/providers/__init__.py | 1 + .../src/crewai_core/auth/providers/auth0.py | 40 +++ .../auth/providers/base_provider.py | 46 ++++ .../crewai_core/auth/providers/entra_id.py | 49 ++++ .../crewai_core/auth/providers/keycloak.py | 38 +++ .../src/crewai_core/auth/providers/okta.py | 48 ++++ .../src/crewai_core/auth/providers/workos.py | 36 +++ lib/crewai-core/src/crewai_core/auth/token.py | 17 ++ lib/crewai-core/src/crewai_core/auth/utils.py | 71 ++++++ lib/crewai-core/src/crewai_core/lock_store.py | 2 +- lib/crewai-core/src/crewai_core/plus_api.py | 232 +++++++++++++++++ lib/crewai-core/src/crewai_core/version.py | 175 ++++++++++++- lib/crewai/src/crewai/auth/__init__.py | 23 +- lib/crewai/src/crewai/auth/constants.py | 9 +- lib/crewai/src/crewai/auth/oauth2.py | 188 +------------- .../src/crewai/auth/providers/__init__.py | 2 +- lib/crewai/src/crewai/auth/providers/auth0.py | 40 +-- .../crewai/auth/providers/base_provider.py | 44 +--- .../src/crewai/auth/providers/entra_id.py | 47 +--- .../src/crewai/auth/providers/keycloak.py | 38 +-- lib/crewai/src/crewai/auth/providers/okta.py | 48 +--- .../src/crewai/auth/providers/workos.py | 36 +-- lib/crewai/src/crewai/auth/token.py | 20 +- lib/crewai/src/crewai/auth/utils.py | 67 +---- lib/crewai/src/crewai/plus_api.py | 232 +---------------- lib/crewai/src/crewai/version.py | 231 ++--------------- .../tests/cli/authentication/test_utils.py | 4 +- lib/crewai/tests/cli/test_plus_api.py | 50 ++-- lib/crewai/tests/cli/test_version.py | 50 ++-- pyproject.toml | 1 + uv.lock | 54 ++++ 53 files changed, 1354 insertions(+), 1917 deletions(-) create mode 100644 lib/crewai-core/src/crewai_core/auth/__init__.py create mode 100644 lib/crewai-core/src/crewai_core/auth/constants.py create mode 100644 lib/crewai-core/src/crewai_core/auth/oauth2.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/__init__.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/auth0.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/base_provider.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/entra_id.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/keycloak.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/okta.py create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/workos.py create mode 100644 lib/crewai-core/src/crewai_core/auth/token.py create mode 100644 lib/crewai-core/src/crewai_core/auth/utils.py create mode 100644 lib/crewai-core/src/crewai_core/plus_api.py diff --git a/lib/cli/src/crewai_cli/authentication/__init__.py b/lib/cli/src/crewai_cli/authentication/__init__.py index 69f963f3d..dedcc8046 100644 --- a/lib/cli/src/crewai_cli/authentication/__init__.py +++ b/lib/cli/src/crewai_cli/authentication/__init__.py @@ -1,3 +1,7 @@ +"""CLI authentication entry point.""" + +from __future__ import annotations + from crewai_cli.authentication.main import AuthenticationCommand diff --git a/lib/cli/src/crewai_cli/authentication/constants.py b/lib/cli/src/crewai_cli/authentication/constants.py index a9457b36a..b1dae41aa 100644 --- a/lib/cli/src/crewai_cli/authentication/constants.py +++ b/lib/cli/src/crewai_cli/authentication/constants.py @@ -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"] diff --git a/lib/cli/src/crewai_cli/authentication/main.py b/lib/cli/src/crewai_cli/authentication/main.py index 2959f7864..5321b9171 100644 --- a/lib/cli/src/crewai_cli/authentication/main.py +++ b/lib/cli/src/crewai_cli/authentication/main.py @@ -1,185 +1,30 @@ -import time -from typing import TYPE_CHECKING, Any, TypeVar, cast -import webbrowser +"""CLI-side authentication wiring. -from crewai_core.token_manager import TokenManager -import httpx -from pydantic import BaseModel, Field -from rich.console import Console +Re-exports the OAuth2 primitives from ``crewai_core.auth`` and overrides the +``_post_login`` hook to also log into the tool repository. +""" -from crewai_cli.authentication.utils import validate_jwt_token -from crewai_cli.config import Settings +from __future__ import annotations + +from crewai_core.auth.oauth2 import ( + AuthenticationCommand as _BaseAuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, + console, +) +from crewai_core.settings import Settings -console = Console() - -TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings") +__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"] -class Oauth2Settings(BaseModel): - 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={}, - ) +class AuthenticationCommand(_BaseAuthenticationCommand): + """CLI-side login that also signs the user into the tool repository.""" - @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_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 _post_login(self) -> None: + self._login_to_tool_repository() def _login_to_tool_repository(self) -> None: - """Login to the tool repository.""" - from crewai_cli.tools.main import ToolCommand try: diff --git a/lib/cli/src/crewai_cli/authentication/providers/__init__.py b/lib/cli/src/crewai_cli/authentication/providers/__init__.py index e69de29bb..723579c03 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/__init__.py +++ b/lib/cli/src/crewai_cli/authentication/providers/__init__.py @@ -0,0 +1 @@ +"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``.""" diff --git a/lib/cli/src/crewai_cli/authentication/providers/auth0.py b/lib/cli/src/crewai_cli/authentication/providers/auth0.py index 1de1bf4b8..110b4784a 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/auth0.py +++ b/lib/cli/src/crewai_cli/authentication/providers/auth0.py @@ -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): - 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 +__all__ = ["Auth0Provider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/base_provider.py b/lib/cli/src/crewai_cli/authentication/providers/base_provider.py index 60f4a78bc..d82bfd15a 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/base_provider.py +++ b/lib/cli/src/crewai_cli/authentication/providers/base_provider.py @@ -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): - 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"] +__all__ = ["BaseProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/entra_id.py b/lib/cli/src/crewai_cli/authentication/providers/entra_id.py index c7ba54085..1ea10db78 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/entra_id.py +++ b/lib/cli/src/crewai_cli/authentication/providers/entra_id.py @@ -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): - 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}" +__all__ = ["EntraIdProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/keycloak.py b/lib/cli/src/crewai_cli/authentication/providers/keycloak.py index 9a076e206..4bbf0be53 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/keycloak.py +++ b/lib/cli/src/crewai_cli/authentication/providers/keycloak.py @@ -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): - 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}" +__all__ = ["KeycloakProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/okta.py b/lib/cli/src/crewai_cli/authentication/providers/okta.py index 2e5e95dd6..530549be5 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/okta.py +++ b/lib/cli/src/crewai_cli/authentication/providers/okta.py @@ -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): - 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}" +__all__ = ["OktaProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/workos.py b/lib/cli/src/crewai_cli/authentication/providers/workos.py index 64f6598ab..b31c72cae 100644 --- a/lib/cli/src/crewai_cli/authentication/providers/workos.py +++ b/lib/cli/src/crewai_cli/authentication/providers/workos.py @@ -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): - 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 +__all__ = ["WorkosProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/token.py b/lib/cli/src/crewai_cli/authentication/token.py index 7e966488c..5bb6b656f 100644 --- a/lib/cli/src/crewai_cli/authentication/token.py +++ b/lib/cli/src/crewai_cli/authentication/token.py @@ -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): - 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 +__all__ = ["AuthError", "get_auth_token"] diff --git a/lib/cli/src/crewai_cli/authentication/utils.py b/lib/cli/src/crewai_cli/authentication/utils.py index 7311b9d42..700c5d16e 100644 --- a/lib/cli/src/crewai_cli/authentication/utils.py +++ b/lib/cli/src/crewai_cli/authentication/utils.py @@ -1,63 +1,8 @@ -from typing import Any +"""Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``.""" -import jwt -from jwt import PyJWKClient +from __future__ import annotations + +from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token -def 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 +__all__ = ["validate_jwt_token"] diff --git a/lib/cli/src/crewai_cli/plus_api.py b/lib/cli/src/crewai_cli/plus_api.py index c655138e5..708712c8c 100644 --- a/lib/cli/src/crewai_cli/plus_api.py +++ b/lib/cli/src/crewai_cli/plus_api.py @@ -1,231 +1,12 @@ -import os -from typing import Any -from urllib.parse import urljoin +"""Re-export of ``crewai_core.plus_api.PlusAPI``. -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 crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai_cli.version import get_crewai_version +from __future__ import annotations + +from crewai_core.plus_api import PlusAPI as PlusAPI -class 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" - ) +__all__ = ["PlusAPI"] diff --git a/lib/cli/src/crewai_cli/version.py b/lib/cli/src/crewai_cli/version.py index d763d636a..cd9cc1d48 100644 --- a/lib/cli/src/crewai_cli/version.py +++ b/lib/cli/src/crewai_cli/version.py @@ -1,210 +1,24 @@ -"""Version utilities for CrewAI CLI.""" +"""Re-exports of version utilities from ``crewai_core.version``. -from collections.abc import Mapping -from datetime import datetime, timedelta -from functools import lru_cache -import json -from pathlib import Path -from typing import Any -from urllib import request -from urllib.error import URLError +Kept as a stable import path for the CLI; new code should import from +``crewai_core.version`` directly. +""" -import appdirs -from crewai_core.version import get_crewai_version as get_crewai_version -from packaging.version import InvalidVersion, Version, parse +from __future__ import annotations + +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) -def _get_cache_file() -> Path: - """Get the path to the version cache file. - - Cached to avoid repeated filesystem operations. - """ - 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 +__all__ = [ + "check_version", + "get_crewai_version", + "get_latest_version_from_pypi", + "is_current_version_yanked", + "is_newer_version_available", +] diff --git a/lib/cli/tests/authentication/test_auth_main.py b/lib/cli/tests/authentication/test_auth_main.py index 362ecf827..5dd417d00 100644 --- a/lib/cli/tests/authentication/test_auth_main.py +++ b/lib/cli/tests/authentication/test_auth_main.py @@ -43,7 +43,7 @@ class TestAuthenticationCommand: "crewai_cli.authentication.main.AuthenticationCommand._display_auth_instructions" ) @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( self, mock_console_print, @@ -82,8 +82,8 @@ class TestAuthenticationCommand: self.auth_command.oauth2_provider._get_domain() == expected_urls["domain"] ) - @patch("crewai_cli.authentication.main.webbrowser") - @patch("crewai_cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.webbrowser") + @patch("crewai_core.auth.oauth2.console.print") def test_display_auth_instructions(self, mock_console_print, mock_webbrowser): device_code_data = { "verification_uri_complete": "https://example.com/auth", @@ -113,8 +113,8 @@ class TestAuthenticationCommand: ], ) @pytest.mark.parametrize("has_expiration", [True, False]) - @patch("crewai_cli.authentication.main.validate_jwt_token") - @patch("crewai_cli.authentication.main.TokenManager.save_tokens") + @patch("crewai_core.auth.oauth2.validate_jwt_token") + @patch("crewai_core.auth.oauth2.TokenManager.save_tokens") def test_validate_and_save_token( self, mock_save_tokens, @@ -164,7 +164,7 @@ class TestAuthenticationCommand: @patch("crewai_cli.tools.main.ToolCommand") @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( self, mock_console_print, mock_settings, mock_tool_command ): @@ -196,7 +196,7 @@ class TestAuthenticationCommand: mock_console_print.assert_has_calls(expected_calls) @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( self, mock_console_print, mock_tool_command ): @@ -226,7 +226,7 @@ class TestAuthenticationCommand: ] 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): mock_response = MagicMock() mock_response.json.return_value = { @@ -262,8 +262,8 @@ class TestAuthenticationCommand: "verification_uri_complete": "https://example.com/auth", } - @patch("crewai_cli.authentication.main.httpx.post") - @patch("crewai_cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.httpx.post") + @patch("crewai_core.auth.oauth2.console.print") def test_poll_for_token_success(self, mock_console_print, mock_post): mock_response_success = MagicMock() mock_response_success.status_code = 200 @@ -311,8 +311,8 @@ class TestAuthenticationCommand: ] mock_console_print.assert_has_calls(expected_calls) - @patch("crewai_cli.authentication.main.httpx.post") - @patch("crewai_cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.httpx.post") + @patch("crewai_core.auth.oauth2.console.print") def test_poll_for_token_timeout(self, mock_console_print, mock_post): mock_response_pending = MagicMock() mock_response_pending.status_code = 400 @@ -330,7 +330,7 @@ class TestAuthenticationCommand: "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): """Test the method to poll for token (error path).""" # Setup mock to return error diff --git a/lib/cli/tests/authentication/test_utils.py b/lib/cli/tests/authentication/test_utils.py index fd8f21921..d23425717 100644 --- a/lib/cli/tests/authentication/test_utils.py +++ b/lib/cli/tests/authentication/test_utils.py @@ -6,8 +6,8 @@ import jwt from crewai_cli.authentication.utils import validate_jwt_token -@patch("crewai_cli.authentication.utils.PyJWKClient", return_value=MagicMock()) -@patch("crewai_cli.authentication.utils.jwt") +@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock()) +@patch("crewai_core.auth.utils.jwt") class TestUtils(unittest.TestCase): def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): mock_jwt.decode.return_value = {"exp": 1719859200} diff --git a/lib/cli/tests/test_plus_api.py b/lib/cli/tests/test_plus_api.py index e3f938362..e10a01f70 100644 --- a/lib/cli/tests/test_plus_api.py +++ b/lib/cli/tests/test_plus_api.py @@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase): self.assertIn("CrewAI-CLI/", self.api.headers["User-Agent"]) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -48,8 +48,8 @@ class TestPlusAPI(unittest.TestCase): **kwargs, ) - @patch("crewai_cli.plus_api.Settings") - @patch("crewai_cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_login_to_tool_repository_with_org_uuid( self, mock_client_class, mock_settings_class ): @@ -71,7 +71,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -82,8 +82,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai_cli.plus_api.Settings") - @patch("crewai_cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -103,7 +103,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -131,8 +131,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai_cli.plus_api.Settings") - @patch("crewai_cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -170,7 +170,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -198,7 +198,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_client_instance = MagicMock() mock_response = MagicMock() @@ -213,35 +213,35 @@ class TestPlusAPI(unittest.TestCase): ) 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): self.api.deploy_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.deploy_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.crew_status_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.crew_status_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.crew_by_name("test_project") 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" ) - @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): self.api.crew_by_uuid("test_uuid") 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" ) - @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): self.api.delete_crew_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.delete_crew_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.list_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): payload = {"name": "test_crew"} self.api.create_crew(payload) @@ -292,7 +292,7 @@ class TestPlusAPI(unittest.TestCase): "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": ""}) def test_custom_base_url(self, mock_settings_class): mock_settings = MagicMock() @@ -333,7 +333,7 @@ async def test_get_agent(mock_async_client_class): @pytest.mark.asyncio @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): org_uuid = "test-org-uuid" mock_settings = MagicMock() diff --git a/lib/cli/tests/test_version.py b/lib/cli/tests/test_version.py index b6794b00a..2d6d38eee 100644 --- a/lib/cli/tests/test_version.py +++ b/lib/cli/tests/test_version.py @@ -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 ( - _find_latest_non_yanked_version, - _get_cache_file, - _is_cache_valid, - _is_version_yanked, get_crewai_version, get_latest_version_from_pypi, is_current_version_yanked, 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: @@ -60,8 +62,8 @@ class TestVersionChecking: cache_data = {"version": "1.0.0"} assert _is_cache_valid(cache_data) is False - @patch("crewai_cli.version.Path.exists") - @patch("crewai_cli.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_success( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -82,8 +84,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version == "2.0.0" - @patch("crewai_cli.version.Path.exists") - @patch("crewai_cli.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_failure( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -97,8 +99,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version is None - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_true( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -111,8 +113,8 @@ class TestVersionChecking: assert current == "1.0.0" assert latest == "2.0.0" - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_false( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -125,8 +127,8 @@ class TestVersionChecking: assert current == "2.0.0" assert latest == "2.0.0" - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_with_none_latest( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -260,8 +262,8 @@ class TestIsVersionYanked: class TestIsCurrentVersionYanked: """Test is_current_version_yanked public function.""" - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_reads_from_valid_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked: assert is_yanked is True assert reason == "bad release" - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_not_yanked_from_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False assert reason == "" - @patch("crewai_cli.version.get_latest_version_from_pypi") - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_triggers_fetch_on_stale_cache( self, mock_cache_file: MagicMock, @@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False mock_fetch.assert_called_once() - @patch("crewai_cli.version.get_latest_version_from_pypi") - @patch("crewai_cli.version.get_crewai_version") - @patch("crewai_cli.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_returns_false_on_fetch_failure( self, mock_cache_file: MagicMock, diff --git a/lib/crewai-core/pyproject.toml b/lib/crewai-core/pyproject.toml index f3d386dd5..92447b057 100644 --- a/lib/crewai-core/pyproject.toml +++ b/lib/crewai-core/pyproject.toml @@ -10,7 +10,10 @@ requires-python = ">=3.10, <3.14" dependencies = [ "appdirs~=1.4.4", "cryptography>=42.0", + "httpx~=0.28.1", + "packaging>=23.0", "portalocker~=2.7.0", + "pyjwt>=2.9.0,<3", "pydantic>=2.11.9,<2.13", "rich>=13.7.1", "opentelemetry-api~=1.34.0", diff --git a/lib/crewai-core/src/crewai_core/auth/__init__.py b/lib/crewai-core/src/crewai_core/auth/__init__.py new file mode 100644 index 000000000..fd0f1c102 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/__init__.py @@ -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", +] diff --git a/lib/crewai-core/src/crewai_core/auth/constants.py b/lib/crewai-core/src/crewai_core/auth/constants.py new file mode 100644 index 000000000..e8daef120 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/constants.py @@ -0,0 +1,8 @@ +"""Authentication constants.""" + +from __future__ import annotations + +from typing import Final + + +ALGORITHMS: Final[list[str]] = ["RS256"] diff --git a/lib/crewai-core/src/crewai_core/auth/oauth2.py b/lib/crewai-core/src/crewai_core/auth/oauth2.py new file mode 100644 index 000000000..744a483b4 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/oauth2.py @@ -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.""" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/__init__.py b/lib/crewai-core/src/crewai_core/auth/providers/__init__.py new file mode 100644 index 000000000..c495fe55b --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/__init__.py @@ -0,0 +1 @@ +"""OAuth2 authentication providers.""" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/auth0.py b/lib/crewai-core/src/crewai_core/auth/providers/auth0.py new file mode 100644 index 000000000..14e5b705f --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/auth0.py @@ -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 diff --git a/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py b/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py new file mode 100644 index 000000000..b2332e347 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py @@ -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"] diff --git a/lib/crewai-core/src/crewai_core/auth/providers/entra_id.py b/lib/crewai-core/src/crewai_core/auth/providers/entra_id.py new file mode 100644 index 000000000..1e5a8a279 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/entra_id.py @@ -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}" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/keycloak.py b/lib/crewai-core/src/crewai_core/auth/providers/keycloak.py new file mode 100644 index 000000000..6c198660f --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/keycloak.py @@ -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}" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/okta.py b/lib/crewai-core/src/crewai_core/auth/providers/okta.py new file mode 100644 index 000000000..5c672ec00 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/okta.py @@ -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}" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/workos.py b/lib/crewai-core/src/crewai_core/auth/providers/workos.py new file mode 100644 index 000000000..2dcd6a1ed --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/workos.py @@ -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 diff --git a/lib/crewai-core/src/crewai_core/auth/token.py b/lib/crewai-core/src/crewai_core/auth/token.py new file mode 100644 index 000000000..42c40b8fa --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/token.py @@ -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 diff --git a/lib/crewai-core/src/crewai_core/auth/utils.py b/lib/crewai-core/src/crewai_core/auth/utils.py new file mode 100644 index 000000000..cf9ea80c2 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/utils.py @@ -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 diff --git a/lib/crewai-core/src/crewai_core/lock_store.py b/lib/crewai-core/src/crewai_core/lock_store.py index 16705d3ae..0f09fa7f6 100644 --- a/lib/crewai-core/src/crewai_core/lock_store.py +++ b/lib/crewai-core/src/crewai_core/lock_store.py @@ -44,7 +44,7 @@ def _redis_available() -> bool: @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.""" from redis import Redis diff --git a/lib/crewai-core/src/crewai_core/plus_api.py b/lib/crewai-core/src/crewai_core/plus_api.py new file mode 100644 index 000000000..39f34e1b8 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/plus_api.py @@ -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" + ) diff --git a/lib/crewai-core/src/crewai_core/version.py b/lib/crewai-core/src/crewai_core/version.py index 6cf87c866..e51fe51bd 100644 --- a/lib/crewai-core/src/crewai_core/version.py +++ b/lib/crewai-core/src/crewai_core/version.py @@ -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 functools import cache +from collections.abc import Mapping +from datetime import datetime, timedelta +from functools import cache, 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 packaging.version import InvalidVersion, Version, parse @cache @@ -21,3 +36,159 @@ def get_crewai_version() -> str: return importlib.metadata.version("crewai-core") except importlib.metadata.PackageNotFoundError: 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 diff --git a/lib/crewai/src/crewai/auth/__init__.py b/lib/crewai/src/crewai/auth/__init__.py index f33f09a58..c30b37f9c 100644 --- a/lib/crewai/src/crewai/auth/__init__.py +++ b/lib/crewai/src/crewai/auth/__init__.py @@ -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 crewai.auth.token import AuthError, get_auth_token +from __future__ import annotations + +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", +] diff --git a/lib/crewai/src/crewai/auth/constants.py b/lib/crewai/src/crewai/auth/constants.py index 3fa25aa4e..b1dae41aa 100644 --- a/lib/crewai/src/crewai/auth/constants.py +++ b/lib/crewai/src/crewai/auth/constants.py @@ -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"] diff --git a/lib/crewai/src/crewai/auth/oauth2.py b/lib/crewai/src/crewai/auth/oauth2.py index 8500a2ede..8e05ebff0 100644 --- a/lib/crewai/src/crewai/auth/oauth2.py +++ b/lib/crewai/src/crewai/auth/oauth2.py @@ -1,184 +1,12 @@ -"""OAuth2 authentication for the CrewAI platform.""" +"""Re-exports of OAuth2 primitives from ``crewai_core.auth.oauth2``.""" -import time -from typing import TYPE_CHECKING, Any, TypeVar, cast -import webbrowser +from __future__ import annotations -from crewai_core.settings import Settings -from crewai_core.token_manager import TokenManager -import httpx -from pydantic import BaseModel, Field -from rich.console import Console - -from crewai.auth.utils import validate_jwt_token +from crewai_core.auth.oauth2 import ( + AuthenticationCommand as AuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, +) -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: - """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.""" +__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"] diff --git a/lib/crewai/src/crewai/auth/providers/__init__.py b/lib/crewai/src/crewai/auth/providers/__init__.py index c495fe55b..723579c03 100644 --- a/lib/crewai/src/crewai/auth/providers/__init__.py +++ b/lib/crewai/src/crewai/auth/providers/__init__.py @@ -1 +1 @@ -"""OAuth2 authentication providers.""" +"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``.""" diff --git a/lib/crewai/src/crewai/auth/providers/auth0.py b/lib/crewai/src/crewai/auth/providers/auth0.py index a86c33cc5..110b4784a 100644 --- a/lib/crewai/src/crewai/auth/providers/auth0.py +++ b/lib/crewai/src/crewai/auth/providers/auth0.py @@ -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): - """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 +__all__ = ["Auth0Provider"] diff --git a/lib/crewai/src/crewai/auth/providers/base_provider.py b/lib/crewai/src/crewai/auth/providers/base_provider.py index 926e3ca1d..d82bfd15a 100644 --- a/lib/crewai/src/crewai/auth/providers/base_provider.py +++ b/lib/crewai/src/crewai/auth/providers/base_provider.py @@ -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): - """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"] +__all__ = ["BaseProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/entra_id.py b/lib/crewai/src/crewai/auth/providers/entra_id.py index 1bd1dc9a8..1ea10db78 100644 --- a/lib/crewai/src/crewai/auth/providers/entra_id.py +++ b/lib/crewai/src/crewai/auth/providers/entra_id.py @@ -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): - """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}" +__all__ = ["EntraIdProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/keycloak.py b/lib/crewai/src/crewai/auth/providers/keycloak.py index b2b82947e..4bbf0be53 100644 --- a/lib/crewai/src/crewai/auth/providers/keycloak.py +++ b/lib/crewai/src/crewai/auth/providers/keycloak.py @@ -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): - """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}" +__all__ = ["KeycloakProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/okta.py b/lib/crewai/src/crewai/auth/providers/okta.py index 13f051360..530549be5 100644 --- a/lib/crewai/src/crewai/auth/providers/okta.py +++ b/lib/crewai/src/crewai/auth/providers/okta.py @@ -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): - """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}" +__all__ = ["OktaProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/workos.py b/lib/crewai/src/crewai/auth/providers/workos.py index dda5fe62f..b31c72cae 100644 --- a/lib/crewai/src/crewai/auth/providers/workos.py +++ b/lib/crewai/src/crewai/auth/providers/workos.py @@ -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): - """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 +__all__ = ["WorkosProvider"] diff --git a/lib/crewai/src/crewai/auth/token.py b/lib/crewai/src/crewai/auth/token.py index c3eac3927..5bb6b656f 100644 --- a/lib/crewai/src/crewai/auth/token.py +++ b/lib/crewai/src/crewai/auth/token.py @@ -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): - """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 +__all__ = ["AuthError", "get_auth_token"] diff --git a/lib/crewai/src/crewai/auth/utils.py b/lib/crewai/src/crewai/auth/utils.py index c8e406793..700c5d16e 100644 --- a/lib/crewai/src/crewai/auth/utils.py +++ b/lib/crewai/src/crewai/auth/utils.py @@ -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 jwt import PyJWKClient +from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token -def 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 +__all__ = ["validate_jwt_token"] diff --git a/lib/crewai/src/crewai/plus_api.py b/lib/crewai/src/crewai/plus_api.py index 3dd7c87d1..e8e1722e7 100644 --- a/lib/crewai/src/crewai/plus_api.py +++ b/lib/crewai/src/crewai/plus_api.py @@ -1,230 +1,12 @@ -"""CrewAI+ API client.""" +"""Re-export of ``crewai_core.plus_api.PlusAPI``. -import os -from typing import Any -from urllib.parse import urljoin +Kept as a stable import path for the framework; new code should import from +``crewai_core.plus_api`` directly. +""" -from crewai_core.settings import Settings -import httpx +from __future__ import annotations -from crewai.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai.version import get_crewai_version +from crewai_core.plus_api import PlusAPI as PlusAPI -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" - ) +__all__ = ["PlusAPI"] diff --git a/lib/crewai/src/crewai/version.py b/lib/crewai/src/crewai/version.py index 4aac4252a..2016621b5 100644 --- a/lib/crewai/src/crewai/version.py +++ b/lib/crewai/src/crewai/version.py @@ -1,215 +1,24 @@ -"""Version utilities for CrewAI.""" +"""Re-exports of version utilities from ``crewai_core.version``. -from collections.abc import Mapping -from datetime import datetime, timedelta -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 +Kept as a stable import path for the framework; new code should import from +``crewai_core.version`` directly. +""" -import appdirs -from packaging.version import InvalidVersion, Version, parse +from __future__ import annotations + +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) -def _get_cache_file() -> Path: - """Get the path to the version cache file. - - Cached to avoid repeated filesystem operations. - """ - 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 +__all__ = [ + "check_version", + "get_crewai_version", + "get_latest_version_from_pypi", + "is_current_version_yanked", + "is_newer_version_available", +] diff --git a/lib/crewai/tests/cli/authentication/test_utils.py b/lib/crewai/tests/cli/authentication/test_utils.py index dbd16c842..22f5357f2 100644 --- a/lib/crewai/tests/cli/authentication/test_utils.py +++ b/lib/crewai/tests/cli/authentication/test_utils.py @@ -6,8 +6,8 @@ import jwt from crewai.auth.utils import validate_jwt_token -@patch("crewai.auth.utils.PyJWKClient", return_value=MagicMock()) -@patch("crewai.auth.utils.jwt") +@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock()) +@patch("crewai_core.auth.utils.jwt") class TestUtils(unittest.TestCase): def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): mock_jwt.decode.return_value = {"exp": 1719859200} diff --git a/lib/crewai/tests/cli/test_plus_api.py b/lib/crewai/tests/cli/test_plus_api.py index 76290d357..f38eef9b1 100644 --- a/lib/crewai/tests/cli/test_plus_api.py +++ b/lib/crewai/tests/cli/test_plus_api.py @@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase): self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"]) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -32,7 +32,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -60,8 +60,8 @@ class TestPlusAPI(unittest.TestCase): **kwargs, ) - @patch("crewai.plus_api.Settings") - @patch("crewai.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_login_to_tool_repository_with_org_uuid( self, mock_client_class, mock_settings_class ): @@ -83,7 +83,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -94,8 +94,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.plus_api.Settings") - @patch("crewai.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -115,7 +115,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -143,8 +143,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.plus_api.Settings") - @patch("crewai.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -182,7 +182,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -210,7 +210,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -251,7 +251,7 @@ class TestPlusAPI(unittest.TestCase): ) 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): mock_client_instance = MagicMock() mock_response = MagicMock() @@ -266,35 +266,35 @@ class TestPlusAPI(unittest.TestCase): ) 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): self.api.deploy_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.deploy_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.crew_status_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.crew_status_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.crew_by_name("test_project") 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" ) - @patch("crewai.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_crew_by_uuid(self, mock_make_request): self.api.crew_by_uuid("test_uuid") 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" ) - @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): self.api.delete_crew_by_name("test_project") mock_make_request.assert_called_once_with( "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): self.api.delete_crew_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "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): self.api.list_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): payload = {"name": "test_crew"} self.api.create_crew(payload) @@ -345,7 +345,7 @@ class TestPlusAPI(unittest.TestCase): "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": ""}) def test_custom_base_url(self, mock_settings_class): mock_settings = MagicMock() @@ -386,7 +386,7 @@ async def test_get_agent(mock_async_client_class): @pytest.mark.asyncio @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): org_uuid = "test-org-uuid" mock_settings = MagicMock() diff --git a/lib/crewai/tests/cli/test_version.py b/lib/crewai/tests/cli/test_version.py index 39cbbaaa2..c5ada8058 100644 --- a/lib/crewai/tests/cli/test_version.py +++ b/lib/crewai/tests/cli/test_version.py @@ -7,15 +7,17 @@ from unittest.mock import MagicMock, patch from crewai import __version__ from crewai.version import ( - _find_latest_non_yanked_version, - _get_cache_file, - _is_cache_valid, - _is_version_yanked, get_crewai_version, get_latest_version_from_pypi, is_current_version_yanked, 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: @@ -60,8 +62,8 @@ class TestVersionChecking: cache_data = {"version": "1.0.0"} assert _is_cache_valid(cache_data) is False - @patch("crewai.version.Path.exists") - @patch("crewai.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_success( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -82,8 +84,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version == "2.0.0" - @patch("crewai.version.Path.exists") - @patch("crewai.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_failure( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -97,8 +99,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version is None - @patch("crewai.version.get_crewai_version") - @patch("crewai.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_true( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -111,8 +113,8 @@ class TestVersionChecking: assert current == "1.0.0" assert latest == "2.0.0" - @patch("crewai.version.get_crewai_version") - @patch("crewai.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_false( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -125,8 +127,8 @@ class TestVersionChecking: assert current == "2.0.0" assert latest == "2.0.0" - @patch("crewai.version.get_crewai_version") - @patch("crewai.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_with_none_latest( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -260,8 +262,8 @@ class TestIsVersionYanked: class TestIsCurrentVersionYanked: """Test is_current_version_yanked public function.""" - @patch("crewai.version.get_crewai_version") - @patch("crewai.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_reads_from_valid_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked: assert is_yanked is True assert reason == "bad release" - @patch("crewai.version.get_crewai_version") - @patch("crewai.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_not_yanked_from_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False assert reason == "" - @patch("crewai.version.get_latest_version_from_pypi") - @patch("crewai.version.get_crewai_version") - @patch("crewai.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_triggers_fetch_on_stale_cache( self, mock_cache_file: MagicMock, @@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False mock_fetch.assert_called_once() - @patch("crewai.version.get_latest_version_from_pypi") - @patch("crewai.version.get_crewai_version") - @patch("crewai.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_returns_false_on_fetch_failure( self, mock_cache_file: MagicMock, diff --git a/pyproject.toml b/pyproject.toml index 988cb7b28..fe3b21414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "types-psycopg2==2.9.21.20251012", "types-pymysql==1.1.0.20250916", "types-aiofiles~=25.1.0", + "types-redis~=4.6", "commitizen>=4.13.9", "pip-audit==2.9.0", ] diff --git a/uv.lock b/uv.lock index a64bd562e..f4d0b2634 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,7 @@ dev = [ { name = "types-psycopg2", specifier = "==2.9.21.20251012" }, { name = "types-pymysql", specifier = "==1.1.0.20250916" }, { name = "types-pyyaml", specifier = "==6.0.*" }, + { name = "types-redis", specifier = "~=4.6" }, { name = "types-regex", specifier = "==2026.1.15.*" }, { name = "types-requests", specifier = "~=2.31.0.6" }, { name = "vcrpy", specifier = "==7.0.0" }, @@ -1473,11 +1474,14 @@ source = { editable = "lib/crewai-core" } dependencies = [ { name = "appdirs" }, { name = "cryptography" }, + { name = "httpx" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, + { name = "packaging" }, { name = "portalocker" }, { name = "pydantic" }, + { name = "pyjwt" }, { name = "rich" }, { name = "tomli" }, ] @@ -1486,11 +1490,14 @@ dependencies = [ requires-dist = [ { name = "appdirs", specifier = "~=1.4.4" }, { name = "cryptography", specifier = ">=42.0" }, + { name = "httpx", specifier = "~=0.28.1" }, { name = "opentelemetry-api", specifier = "~=1.34.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, { name = "opentelemetry-sdk", specifier = "~=1.34.0" }, + { name = "packaging", specifier = ">=23.0" }, { name = "portalocker", specifier = "~=2.7.0" }, { name = "pydantic", specifier = ">=2.11.9,<2.13" }, + { name = "pyjwt", specifier = ">=2.9.0,<3" }, { name = "rich", specifier = ">=13.7.1" }, { 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" }, ] +[[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]] name = "types-psycopg2" 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" }, ] +[[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]] name = "types-pyyaml" 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" }, ] +[[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]] name = "types-regex" 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" }, ] +[[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]] name = "typing-extensions" version = "4.15.0"