diff --git a/conda-store-server/conda_store_server/_internal/action/generate_lockfile.py b/conda-store-server/conda_store_server/_internal/action/generate_lockfile.py index cb36ffda4..f4824ba15 100644 --- a/conda-store-server/conda_store_server/_internal/action/generate_lockfile.py +++ b/conda-store-server/conda_store_server/_internal/action/generate_lockfile.py @@ -28,7 +28,7 @@ def action_solve_lockfile( lockfile_filename = pathlib.Path.cwd() / "conda-lock.yaml" with environment_filename.open("w") as f: - json.dump(specification.dict(), f) + json.dump(specification.model_dump(), f) context.log.info( "Note that the output of `conda config --show` displayed below only reflects " @@ -82,7 +82,7 @@ def action_save_lockfile( ): # Note: this calls dict on specification so that the version field is # part of the output - lockfile = specification.dict()["lockfile"] + lockfile = specification.model_dump()["lockfile"] lockfile_filename = pathlib.Path.cwd() / "conda-lock.yaml" with lockfile_filename.open("w") as f: diff --git a/conda-store-server/conda_store_server/_internal/action/install_specification.py b/conda-store-server/conda_store_server/_internal/action/install_specification.py index 8398b0e30..31cbf5226 100644 --- a/conda-store-server/conda_store_server/_internal/action/install_specification.py +++ b/conda-store-server/conda_store_server/_internal/action/install_specification.py @@ -17,7 +17,7 @@ def action_install_specification( ): environment_filename = pathlib.Path.cwd() / "environment.yaml" with environment_filename.open("w") as f: - json.dump(specification.dict(), f) + json.dump(specification.model_dump(), f) command = [ conda_command, diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index bbd170a23..da3d5bcca 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -8,11 +8,17 @@ import os import re import sys -from typing import Any, Callable, Dict, List, Optional, TypeAlias, Union +from typing import Annotated, Any, Callable, Dict, List, Optional, TypeAlias, Union from conda_lock.lockfile.v1.models import Lockfile -from pydantic import BaseModel, Field, ValidationError, constr, validator -from pydantic.error_wrappers import ErrorWrapper +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + StringConstraints, + ValidationError, +) from conda_store_server._internal import conda_utils, utils @@ -36,7 +42,9 @@ def _datetime_factory(offset: datetime.timedelta): # Authentication Schema ######################### -RoleBindings: TypeAlias = Dict[constr(regex=ARN_ALLOWED), List[str]] +RoleBindings: TypeAlias = Dict[ + Annotated[str, StringConstraints(pattern=ARN_ALLOWED)], List[str] +] class Permissions(enum.Enum): @@ -82,40 +90,32 @@ class StorageBackend(enum.Enum): class CondaChannel(BaseModel): id: int name: str - last_update: Optional[datetime.datetime] - - class Config: - orm_mode = True + last_update: Optional[datetime.datetime] = None + model_config = ConfigDict(from_attributes=True) class CondaPackageBuild(BaseModel): id: int build: str sha256: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class CondaPackage(BaseModel): id: int channel: CondaChannel - license: Optional[str] + license: Optional[str] = None name: str version: str - summary: Optional[str] - - class Config: - orm_mode = True + summary: Optional[str] = None + model_config = ConfigDict(from_attributes=True) class NamespaceRoleMapping(BaseModel): id: int entity: str role: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class NamespaceRoleMappingV2(BaseModel): @@ -123,23 +123,19 @@ class NamespaceRoleMappingV2(BaseModel): namespace: str other_namespace: str role: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def from_list(cls, lst): - return cls(**{k: v for k, v in zip(cls.__fields__.keys(), lst, strict=False)}) + return cls(**{k: v for k, v in zip(cls.model_fields.keys(), lst, strict=False)}) class Namespace(BaseModel): id: int - name: constr(regex=f"^[{ALLOWED_CHARACTERS}]+$") # noqa: F722 + name: Annotated[str, StringConstraints(pattern=f"^[{ALLOWED_CHARACTERS}]+$")] # noqa: F722 metadata_: Dict[str, Any] = None role_mappings: List[NamespaceRoleMapping] = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class Specification(BaseModel): @@ -148,12 +144,11 @@ class Specification(BaseModel): spec: dict sha256: str created_on: datetime.datetime - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) -class BuildArtifactType(enum.Enum): +# Use str mixin to allow pydantic to serialize Settings models +class BuildArtifactType(str, enum.Enum): DIRECTORY = "DIRECTORY" LOCKFILE = "LOCKFILE" LOGS = "LOGS" @@ -177,28 +172,22 @@ class BuildArtifact(BaseModel): id: int artifact_type: BuildArtifactType key: str - - class Config: - orm_mode = True - use_enum_values = True + model_config = ConfigDict(from_attributes=True, use_enum_values=True) class Build(BaseModel): id: int environment_id: int - specification: Optional[Specification] - packages: Optional[List[CondaPackage]] + specification: Optional[Specification] = None + packages: Optional[List[CondaPackage]] = None status: BuildStatus - status_info: Optional[str] + status_info: Optional[str] = None size: int scheduled_on: datetime.datetime - started_on: Optional[datetime.datetime] - ended_on: Optional[datetime.datetime] - build_artifacts: Optional[List[BuildArtifact]] - - class Config: - orm_mode = True - use_enum_values = True + started_on: Optional[datetime.datetime] = None + ended_on: Optional[datetime.datetime] = None + build_artifacts: Optional[List[BuildArtifact]] = None + model_config = ConfigDict(from_attributes=True, use_enum_values=True) class Environment(BaseModel): @@ -206,12 +195,10 @@ class Environment(BaseModel): namespace: Namespace name: str current_build_id: int - current_build: Optional[Build] - - description: Optional[str] + current_build: Optional[Build] = None - class Config: - orm_mode = True + description: Optional[str] = None + model_config = ConfigDict(from_attributes=True) class Settings(BaseModel): @@ -378,67 +365,25 @@ class Settings(BaseModel): metadata={"global": False}, ) - @validator("build_artifacts", each_item=True) - def check_build_artifacts(cls, v): - return BuildArtifactType(v) - @validator("build_artifacts_kept_on_deletion", each_item=True) - def check_build_artifacts_kept_on_deletion(cls, v): - return BuildArtifactType(v) - - class Config: - use_enum_values = True +PipArg = Annotated[str, AfterValidator(lambda v: check_pip(v))] # Conda Environment class CondaSpecificationPip(BaseModel): - pip: List[str] = [] - - @validator("pip", each_item=True) - def check_pip(cls, v): - from pkg_resources import Requirement - - allowed_pip_params = ["--index-url", "--extra-index-url", "--trusted-host"] + pip: List[PipArg] = [] - if v.startswith("--"): - match = re.fullmatch("(.+?)[ =](.*)", v) - if match is None or match.group(1) not in allowed_pip_params: - raise ValueError( - f"Invalid pip option '{v}' supported options are {allowed_pip_params}" - ) - else: - try: - Requirement.parse(v) - except Exception: - raise ValueError( - f'Invalid pypi package dependency "{v}" ensure it follows peps https://peps.python.org/pep-0508/ and https://peps.python.org/pep-0440/' - ) - return v +CondaDep = Annotated[str, AfterValidator(lambda v: check_dependencies(v))] class CondaSpecification(BaseModel): - name: constr(regex=f"^[{ALLOWED_CHARACTERS}]+$") # noqa: F722 channels: List[str] = [] - dependencies: List[Union[str, CondaSpecificationPip]] = [] - variables: Optional[Dict[str, Union[str, int]]] - prefix: Optional[str] + dependencies: List[CondaDep | CondaSpecificationPip] = [] description: Optional[str] = "" - - @validator("dependencies", each_item=True) - def check_dependencies(cls, v): - from conda.models.match_spec import MatchSpec - - if not isinstance(v, str): - return v # ignore pip field - - try: - MatchSpec(v) - except Exception as e: - print(e) - raise ValueError(f"Invalid conda package dependency specification {v}") - - return v + name: Annotated[str, StringConstraints(pattern=f"^[{ALLOWED_CHARACTERS}]+$")] + prefix: Optional[str] = None + variables: Optional[Dict[str, Union[str, int]]] = None @classmethod def parse_obj(cls, specification): @@ -478,7 +423,7 @@ def parse_obj(cls, specification): class LockfileSpecification(BaseModel): - name: constr(regex=f"^[{ALLOWED_CHARACTERS}]+$") # noqa: F722 + name: Annotated[str, StringConstraints(pattern=f"^[{ALLOWED_CHARACTERS}]+$")] # noqa: F722 description: Optional[str] = "" lockfile: Lockfile @@ -494,21 +439,14 @@ def parse_obj(cls, specification): lockfile = specification.get("lockfile") version = lockfile and lockfile.pop("version", None) if version not in (None, 1): - # https://stackoverflow.com/questions/73968566/with-pydantic-how-can-i-create-my-own-validationerror-reason raise ValidationError( - [ - ErrorWrapper( - ValueError("expected no version field or version equal to 1"), - "lockfile -> version", - ) - ], - LockfileSpecification, + "Expected lockfile to have no version field, or version=1", ) return super().parse_obj(specification) - def dict(self): - res = super().dict() + def model_dump(self): + res = super().model_dump() # The dict_for_output method includes the version field into the output # and excludes unset fields. Without the version field present, # conda-lock would reject a lockfile during parsing, so it wouldn't be @@ -567,7 +505,7 @@ class DockerConfigConfig(BaseModel): Cmd: List[str] = ["/bin/sh"] ArgsEscaped: bool = True Image: Optional[str] = None - Volumes: Optional[List[str]] + Volumes: Optional[List[str]] = None WorkingDir: str = "" Entrypoint: Optional[str] = None OnBuild: Optional[str] = None @@ -638,8 +576,8 @@ class APIStatus(enum.Enum): class APIResponse(BaseModel): status: APIStatus - data: Optional[Any] - message: Optional[str] + data: Optional[Any] = None + message: Optional[str] = None class APIPaginatedResponse(APIResponse): @@ -650,7 +588,7 @@ class APIPaginatedResponse(APIResponse): class APIAckResponse(BaseModel): status: APIStatus - message: Optional[str] + message: Optional[str] = None # GET /api/v1 @@ -668,7 +606,7 @@ class APIGetPermissionData(BaseModel): primary_namespace: str entity_permissions: Dict[str, List[str]] entity_roles: Dict[str, List[str]] - expiration: Optional[datetime.datetime] + expiration: Optional[datetime.datetime] = None class APIGetPermission(APIResponse): @@ -769,3 +707,64 @@ class APIPutSetting(APIResponse): # GET /api/v1/usage/ class APIGetUsage(APIResponse): data: Dict[str, Dict[str, Any]] + + +def check_pip(v: str) -> str: + """Check that pip options and dependencies are valid. + + Parameters + ---------- + v : str + Pip package name or CLI arg to validate + + Returns + ------- + str + Validated pip package name or CLI arg + """ + from pkg_resources import Requirement + + allowed_pip_params = ["--index-url", "--extra-index-url", "--trusted-host"] + + if v.startswith("--"): + match = re.fullmatch("(.+?)[ =](.*)", v) + if match is None or match.group(1) not in allowed_pip_params: + raise ValueError( + f"Invalid pip option '{v}' supported options are {allowed_pip_params}" + ) + else: + try: + Requirement.parse(v) + except Exception: + raise ValueError( + f'Invalid pypi package dependency "{v}" ensure it follows peps https://peps.python.org/pep-0508/ and https://peps.python.org/pep-0440/' + ) + + return v + + +def check_dependencies(v: str | CondaSpecificationPip) -> str | CondaSpecificationPip: + """Check that the dependency is either a list of pip args or a conda MatchSpec. + + Parameters + ---------- + v : str | CondaSpecificationPip + A list of pip args or a valid conda MatchSpec object + + Returns + ------- + str | CondaSpecificationPip + The validated dependency + """ + from conda.models.match_spec import MatchSpec + + if not isinstance(v, str): + return v # ignore pip field + + try: + MatchSpec(v) + except Exception as e: + print(e) + raise ValueError(f"Invalid conda package dependency specification {v}") + + return v diff --git a/conda-store-server/conda_store_server/_internal/server/templates/setting.html b/conda-store-server/conda_store_server/_internal/server/templates/setting.html index 57669d878..7fbfae500 100644 --- a/conda-store-server/conda_store_server/_internal/server/templates/setting.html +++ b/conda-store-server/conda_store_server/_internal/server/templates/setting.html @@ -20,11 +20,11 @@
Settings must be JSON parsable