From 60b122bcebed4f18f24a26fd43304b9946954683 Mon Sep 17 00:00:00 2001 From: Sindre Langeveld Date: Thu, 19 Mar 2026 15:42:06 +0100 Subject: [PATCH 1/4] ENH: Add RMS session expiration logic + refresh endpoint. --- src/fmu_settings_api/config.py | 3 +- src/fmu_settings_api/deps/__init__.py | 8 - src/fmu_settings_api/deps/session.py | 99 +- src/fmu_settings_api/models/session.py | 3 + src/fmu_settings_api/services/session.py | 4 +- src/fmu_settings_api/session.py | 159 ++-- src/fmu_settings_api/v1/responses.py | 3 + src/fmu_settings_api/v1/routes/project.py | 5 +- src/fmu_settings_api/v1/routes/session.py | 120 ++- tests/conftest.py | 16 +- tests/test_main_config.py | 9 +- tests/test_session_manager.py | 1007 ++++++++++++++------- tests/test_v1/test_deps.py | 174 +--- tests/test_v1/test_project.py | 164 ++-- tests/test_v1/test_rms_deps.py | 6 +- tests/test_v1/test_session.py | 524 ++++++----- 16 files changed, 1304 insertions(+), 1000 deletions(-) diff --git a/src/fmu_settings_api/config.py b/src/fmu_settings_api/config.py index 21d61f5..df93565 100644 --- a/src/fmu_settings_api/config.py +++ b/src/fmu_settings_api/config.py @@ -63,7 +63,8 @@ class APISettings(BaseModel): API_V1_PREFIX: str = Field(default="/api/v1", frozen=True) SESSION_COOKIE_KEY: str = Field(default="fmu_settings_session", frozen=True) - SESSION_EXPIRE_SECONDS: int = Field(default=1200, frozen=True) # 20 minutes + SESSION_EXPIRE_SECONDS: int = Field(default=31556926, frozen=True) # 1 year + RMS_SESSION_EXPIRE_SECONDS: int = Field(default=3600, frozen=True) # 60 minutes APP_NAME: str = Field(default="fmu-settings-api", frozen=True) APP_VERSION: str = Field(default=__version__, frozen=True) TOKEN: str = Field( diff --git a/src/fmu_settings_api/deps/__init__.py b/src/fmu_settings_api/deps/__init__.py index e250dc7..ca3b4cf 100644 --- a/src/fmu_settings_api/deps/__init__.py +++ b/src/fmu_settings_api/deps/__init__.py @@ -8,14 +8,10 @@ from .resource import ResourceServiceDep from .session import ( ProjectSessionDep, - ProjectSessionNoExtendDep, ProjectSessionServiceDep, - ProjectSessionServiceNoExtendDep, ProjectSmdaSessionDep, SessionDep, - SessionNoExtendDep, SessionServiceDep, - SessionServiceNoExtendDep, get_session, get_smda_session, ) @@ -31,13 +27,9 @@ "UserFMUDirDep", "get_session", "SessionDep", - "SessionNoExtendDep", "SessionServiceDep", - "SessionServiceNoExtendDep", "ProjectSessionDep", - "ProjectSessionNoExtendDep", "ProjectSessionServiceDep", - "ProjectSessionServiceNoExtendDep", "RefreshLockDep", "get_smda_session", "ProjectSmdaSessionDep", diff --git a/src/fmu_settings_api/deps/session.py b/src/fmu_settings_api/deps/session.py index 1aeb75f..ce692a3 100644 --- a/src/fmu_settings_api/deps/session.py +++ b/src/fmu_settings_api/deps/session.py @@ -10,43 +10,30 @@ ProjectSession, Session, SessionNotFoundError, - session_manager, + destroy_fmu_session_if_expired, + get_fmu_session, ) -async def get_session( +async def destroy_session_if_expired( fmu_settings_session: Annotated[str | None, Cookie()] = None, -) -> Session: - """Gets a session from the session manager.""" - if not fmu_settings_session: - raise HTTPException( - status_code=401, - detail="No active session found", - headers={ - HttpHeader.WWW_AUTHENTICATE_KEY: HttpHeader.WWW_AUTHENTICATE_COOKIE - }, - ) - try: - return await session_manager.get_session(fmu_settings_session) - except SessionNotFoundError as e: - raise HTTPException( - status_code=401, - detail="Invalid or expired session", - headers={ - HttpHeader.WWW_AUTHENTICATE_KEY: HttpHeader.WWW_AUTHENTICATE_COOKIE - }, - ) from e - except Exception as e: - raise HTTPException(status_code=500, detail=f"Session error: {e}") from e +) -> None: + """Destroys a session from the session manager if it has expired.""" + return ( + await destroy_fmu_session_if_expired(fmu_settings_session) + if fmu_settings_session + else None + ) -SessionDep = Annotated[Session, Depends(get_session)] +DestroySessionIfExpiredDep = Annotated[None, Depends(destroy_session_if_expired)] -async def get_session_no_extend( +async def get_session( + expired_session_dep: DestroySessionIfExpiredDep, fmu_settings_session: Annotated[str | None, Cookie()] = None, ) -> Session: - """Gets a session from the session manager without extending expiration.""" + """Gets an active session from the session manager.""" if not fmu_settings_session: raise HTTPException( status_code=401, @@ -56,13 +43,11 @@ async def get_session_no_extend( }, ) try: - return await session_manager.get_session( - fmu_settings_session, extend_expiration=False - ) + return await get_fmu_session(fmu_settings_session) except SessionNotFoundError as e: raise HTTPException( status_code=401, - detail="Invalid or expired session", + detail="No active session found", headers={ HttpHeader.WWW_AUTHENTICATE_KEY: HttpHeader.WWW_AUTHENTICATE_COOKIE }, @@ -71,14 +56,14 @@ async def get_session_no_extend( raise HTTPException(status_code=500, detail=f"Session error: {e}") from e -SessionNoExtendDep = Annotated[Session, Depends(get_session_no_extend)] +SessionDep = Annotated[Session, Depends(get_session)] async def get_project_session( + session: SessionDep, fmu_settings_session: str | None = Cookie(None), ) -> ProjectSession: """Gets a session with an FMU Project opened from the session manager.""" - session = await get_session(fmu_settings_session) if not isinstance(session, ProjectSession): raise HTTPException( status_code=401, @@ -96,30 +81,6 @@ async def get_project_session( ProjectSessionDep = Annotated[ProjectSession, Depends(get_project_session)] -async def get_project_session_no_extend( - fmu_settings_session: str | None = Cookie(None), -) -> ProjectSession: - """Gets a session with an FMU Project opened from the session manager.""" - session = await get_session_no_extend(fmu_settings_session) - if not isinstance(session, ProjectSession): - raise HTTPException( - status_code=401, - detail="No FMU project directory open", - ) - - if not session.project_fmu_directory.path.exists(): - raise HTTPException( - status_code=404, - detail="Project .fmu directory not found. It may have been deleted.", - ) - return session - - -ProjectSessionNoExtendDep = Annotated[ - ProjectSession, Depends(get_project_session_no_extend) -] - - async def ensure_smda_session(session: Session) -> None: """Raises exceptions if a session is not SMDA-query capable.""" if ( @@ -140,19 +101,19 @@ async def ensure_smda_session(session: Session) -> None: async def get_smda_session( + session: SessionDep, fmu_settings_session: str | None = Cookie(None), ) -> Session: """Gets a session capable of querying SMDA from the session manager.""" - session = await get_session(fmu_settings_session) await ensure_smda_session(session) return session async def get_project_smda_session( + session: ProjectSessionDep, fmu_settings_session: str | None = Cookie(None), ) -> ProjectSession: """Returns a project .fmu session that is SMDA-querying capable.""" - session = await get_project_session(fmu_settings_session) await ensure_smda_session(session) return session @@ -167,17 +128,7 @@ async def get_session_service( return SessionService(session) -async def get_session_service_no_extend( - session: SessionNoExtendDep, -) -> SessionService: - """Returns a SessionService instance without extending session expiration.""" - return SessionService(session) - - SessionServiceDep = Annotated[SessionService, Depends(get_session_service)] -SessionServiceNoExtendDep = Annotated[ - SessionService, Depends(get_session_service_no_extend) -] async def get_project_session_service( @@ -187,16 +138,6 @@ async def get_project_session_service( return SessionService(session) -async def get_project_session_service_no_extend( - session: ProjectSessionNoExtendDep, -) -> SessionService: - """Returns a SessionService for a project session without extending expiration.""" - return SessionService(session) - - ProjectSessionServiceDep = Annotated[ SessionService, Depends(get_project_session_service) ] -ProjectSessionServiceNoExtendDep = Annotated[ - SessionService, Depends(get_project_session_service_no_extend) -] diff --git a/src/fmu_settings_api/models/session.py b/src/fmu_settings_api/models/session.py index dd220ce..92f0f9b 100644 --- a/src/fmu_settings_api/models/session.py +++ b/src/fmu_settings_api/models/session.py @@ -17,5 +17,8 @@ class SessionResponse(BaseResponseModel): expires_at: datetime """Timestamp when the session will expire.""" + rms_expires_at: datetime | None + """Timestamp when the rms session will expire.""" + last_accessed: datetime """Timestamp when the session was last accessed.""" diff --git a/src/fmu_settings_api/services/session.py b/src/fmu_settings_api/services/session.py index b9a2f46..ba7b2a4 100644 --- a/src/fmu_settings_api/services/session.py +++ b/src/fmu_settings_api/services/session.py @@ -21,6 +21,7 @@ add_access_token_to_session as add_token_to_session_manager, add_fmu_project_to_session, add_rms_project_to_session, + get_rms_session_expiration, release_project_lock, remove_fmu_project_from_session, remove_rms_project_from_session, @@ -35,12 +36,13 @@ def __init__(self, session: Session | ProjectSession) -> None: """Initialize the service with a session.""" self._session = session - def get_session_response(self) -> SessionResponse: + async def get_session_response(self) -> SessionResponse: """Get the session data in a serializable format.""" return SessionResponse( id=self._session.id, created_at=self._session.created_at, expires_at=self._session.expires_at, + rms_expires_at=await get_rms_session_expiration(self._session.id), last_accessed=self._session.last_accessed, ) diff --git a/src/fmu_settings_api/session.py b/src/fmu_settings_api/session.py index c235ecd..34b0cd6 100644 --- a/src/fmu_settings_api/session.py +++ b/src/fmu_settings_api/session.py @@ -46,6 +46,8 @@ class RmsSession: """The RMS API executor controlling the worker lifetime.""" project: RmsApiProxy """An opened RMS project that close() can be called against.""" + expires_at: datetime + """Timestamp when the RMS session will expire.""" def cleanup(self, session_id: str = "unknown") -> None: """Closes the RMS project and shuts down the executor.""" @@ -120,7 +122,7 @@ async def _retrieve_session( """Retrieves a session from the storage backend.""" return self.storage.get(session_id, None) - async def _update_session( + async def update_session( self: Self, session_id: str, session: Session | ProjectSession ) -> None: """Stores an updated session back into the session backend.""" @@ -166,13 +168,13 @@ async def create_session( """ session_id = str(uuid4()) now = datetime.now(UTC) - expiration_duration = timedelta(seconds=expire_seconds) + expires_at = now + timedelta(seconds=expire_seconds) session = Session( id=session_id, user_fmu_directory=user_fmu_directory, created_at=now, - expires_at=now + expiration_duration, + expires_at=expires_at, last_accessed=now, access_tokens=AccessTokens(), ) @@ -180,9 +182,7 @@ async def create_session( return session_id - async def get_session( - self: Self, session_id: str, extend_expiration: bool = True - ) -> Session | ProjectSession: + async def get_session(self: Self, session_id: str) -> Session | ProjectSession: """Get the session data for a session id. Params: @@ -192,30 +192,24 @@ async def get_session( The session, if it exists and is valid Raises: - SessionNotFoundError: If the session does not exist or is invalid + SessionNotFoundError: If the session does not exist """ session = await self._retrieve_session(session_id) if not session: raise SessionNotFoundError("No active session found") - now = datetime.now(UTC) - if session.expires_at < now: - await self.destroy_session(session_id) - raise SessionNotFoundError("Invalid or expired session") - - session.last_accessed = now - - if extend_expiration: - expiration_duration = timedelta(seconds=settings.SESSION_EXPIRE_SECONDS) - session.expires_at = now + expiration_duration + session.last_accessed = datetime.now(UTC) - await self._update_session(session_id, session) + await self.update_session(session_id, session) return session session_manager = SessionManager() +# Wrapper functions for the Session Manager singleton + + async def create_fmu_session( user_fmu_directory: UserFMUDirectory, expire_seconds: int = settings.SESSION_EXPIRE_SECONDS, @@ -224,6 +218,49 @@ async def create_fmu_session( return await session_manager.create_session(user_fmu_directory, expire_seconds) +async def get_fmu_session(session_id: str) -> Session | ProjectSession: + """Gets a session from the session manager.""" + return await session_manager.get_session(session_id) + + +async def update_fmu_session(session: Session | ProjectSession) -> None: + """Update a session in the session manager.""" + await session_manager.update_session(session.id, session) + + +async def destroy_fmu_session_if_expired(session_id: str) -> None: + """Destroys the expired sessions in the session manager for the given session_id. + + Cheks the user and rms session for the given session_id and destroy the ones + that has expired. + """ + try: + session = await get_fmu_session(session_id) + except SessionNotFoundError: + return + now = datetime.now(UTC) + rms_session_expiration = await get_rms_session_expiration(session.id) + if rms_session_expiration is not None and rms_session_expiration < now: + await remove_rms_project_from_session(session.id) + + if session.expires_at < now: + await session_manager.destroy_session(session_id) + + +async def refresh_fmu_session(session_id: str) -> Session | ProjectSession: + """Refresh a session in the session manager by extending the expiration time.""" + session: Session | ProjectSession = await get_fmu_session(session_id) + now = datetime.now(UTC) + session.expires_at = now + timedelta(seconds=settings.SESSION_EXPIRE_SECONDS) + if isinstance(session, ProjectSession) and session.rms_session is not None: + session.rms_session.expires_at = now + timedelta( + seconds=settings.RMS_SESSION_EXPIRE_SECONDS + ) + + await update_fmu_session(session) + return session + + async def add_fmu_project_to_session( session_id: str, project_fmu_directory: ProjectFMUDirectory, @@ -239,7 +276,7 @@ async def add_fmu_project_to_session( Raises: SessionNotFoundError: If no valid session was found """ - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) lock_errors = LockErrors() @@ -274,10 +311,28 @@ async def add_fmu_project_to_session( project_path=project_fmu_directory.base_path, user_dir=project_session.user_fmu_directory, ) - await session_manager._store_session(session_id, project_session) + await update_fmu_session(project_session) return project_session +async def add_access_token_to_session(session_id: str, token: AccessToken) -> None: + """Adds a known access token to the current session. + + Raises: + SessionNotFoundError: If no valid session was found + """ + if token.id not in AccessTokens.model_fields: + raise ValueError("Invalid access token id") + + session = await get_fmu_session(session_id) + + access_tokens_dict = session.access_tokens.model_dump() + access_tokens_dict[token.id] = token.key + session.access_tokens = AccessTokens.model_validate(access_tokens_dict) + + await update_fmu_session(session) + + async def try_acquire_project_lock(session_id: str) -> ProjectSession: """Attempts to acquire the project lock for a session. @@ -287,7 +342,7 @@ async def try_acquire_project_lock(session_id: str) -> ProjectSession: Raises: SessionNotFoundError: If no valid session or project is found """ - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) if not isinstance(session, ProjectSession): raise SessionNotFoundError("No FMU project directory open") @@ -301,7 +356,7 @@ async def try_acquire_project_lock(session_id: str) -> ProjectSession: except Exception as e: session.lock_errors.acquire = str(e) - await session_manager._store_session(session_id, session) + await update_fmu_session(session) return session @@ -314,7 +369,7 @@ async def release_project_lock(session_id: str) -> ProjectSession: Raises: SessionNotFoundError: If no valid session or project is found """ - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) if not isinstance(session, ProjectSession): raise SessionNotFoundError("No FMU project directory open") @@ -328,7 +383,7 @@ async def release_project_lock(session_id: str) -> ProjectSession: except Exception as e: session.lock_errors.release = str(e) - await session_manager._store_session(session_id, session) + await update_fmu_session(session) return session @@ -343,7 +398,7 @@ async def refresh_project_lock(session_id: str) -> ProjectSession: Raises: SessionNotFoundError: If no valid session or project is found """ - session = await session_manager.get_session(session_id, extend_expiration=False) + session = await get_fmu_session(session_id) if not isinstance(session, ProjectSession): raise SessionNotFoundError("No FMU project directory open") @@ -356,7 +411,7 @@ async def refresh_project_lock(session_id: str) -> ProjectSession: except Exception as e: session.lock_errors.refresh = str(e) - await session_manager._update_session(session_id, session) + await update_fmu_session(session) return session @@ -369,7 +424,7 @@ async def remove_fmu_project_from_session(session_id: str) -> Session: Raises: SessionNotFoundError: If no valid session was found """ - maybe_project_session = await session_manager.get_session(session_id) + maybe_project_session = await get_fmu_session(session_id) if not isinstance(maybe_project_session, ProjectSession): return maybe_project_session @@ -390,7 +445,7 @@ async def remove_fmu_project_from_session(session_id: str) -> Session: project_session_dict.pop("rms_session", None) session = Session(**project_session_dict) - await session_manager._store_session(session_id, session) + await update_fmu_session(session) return session @@ -405,7 +460,7 @@ async def add_rms_project_to_session( Raises: SessionNotFoundError: If no valid session was found """ - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) if not isinstance(session, ProjectSession): raise SessionNotFoundError("No FMU project directory open") @@ -413,14 +468,17 @@ async def add_rms_project_to_session( if session.rms_session is not None: session.rms_session.cleanup(session_id) - session.rms_session = RmsSession(executor=executor, project=rms_project) - await session_manager._store_session(session_id, session) + rms_expires_at = datetime.now(UTC) + timedelta( + seconds=settings.RMS_SESSION_EXPIRE_SECONDS + ) + session.rms_session = RmsSession( + executor=executor, project=rms_project, expires_at=rms_expires_at + ) + await update_fmu_session(session) return session -async def remove_rms_project_from_session( - session_id: str, cleanup: bool = True -) -> ProjectSession: +async def remove_rms_project_from_session(session_id: str) -> ProjectSession: """Removes (closes) an open RMS project from a project session. If `cleanup` is False, the RMS worker and project will not be cleaned up. This is @@ -432,38 +490,27 @@ async def remove_rms_project_from_session( Raises: SessionNotFoundError: If no valid session was found """ - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) if not isinstance(session, ProjectSession): raise SessionNotFoundError("No FMU project directory open") - if session.rms_session is not None and cleanup: + if session.rms_session is not None: session.rms_session.cleanup(session_id) session.rms_session = None - await session_manager._store_session(session_id, session) + await update_fmu_session(session) return session -async def add_access_token_to_session(session_id: str, token: AccessToken) -> None: - """Adds a known access token to the current session. +async def get_rms_session_expiration(session_id: str) -> datetime | None: + """Get the expiration time of an RMS session. - Raises: - SessionNotFoundError: If no valid session was found + If the user session with session_id contains an open RMS session, + the expiration time of this RMS session is returned. """ - if token.id not in AccessTokens.model_fields: - raise ValueError("Invalid access token id") - - session = await session_manager.get_session(session_id) - - access_tokens_dict = session.access_tokens.model_dump() - access_tokens_dict[token.id] = token.key - session.access_tokens = AccessTokens.model_validate(access_tokens_dict) - - await session_manager._store_session(session_id, session) - - -async def destroy_fmu_session(session_id: str) -> None: - """Destroys a session in the session manager.""" - await session_manager.destroy_session(session_id) + session = await get_fmu_session(session_id) + if isinstance(session, ProjectSession) and session.rms_session is not None: + return session.rms_session.expires_at + return None diff --git a/src/fmu_settings_api/v1/responses.py b/src/fmu_settings_api/v1/responses.py index bf64032..756f8c8 100644 --- a/src/fmu_settings_api/v1/responses.py +++ b/src/fmu_settings_api/v1/responses.py @@ -34,6 +34,9 @@ def inline_add_response( } +## Session responses + + CreateSessionResponses: Final[Responses] = { **inline_add_response( 401, diff --git a/src/fmu_settings_api/v1/routes/project.py b/src/fmu_settings_api/v1/routes/project.py index 572df73..d7fc631 100644 --- a/src/fmu_settings_api/v1/routes/project.py +++ b/src/fmu_settings_api/v1/routes/project.py @@ -29,7 +29,6 @@ from fmu_settings_api.deps import ( ProjectServiceDep, ProjectSessionServiceDep, - ProjectSessionServiceNoExtendDep, RefreshLockDep, ResourceServiceDep, SessionServiceDep, @@ -680,7 +679,7 @@ async def post_lock_release(session_service: ProjectSessionServiceDep) -> Messag }, ) async def post_lock_refresh( - session_service: ProjectSessionServiceNoExtendDep, + session_service: ProjectSessionServiceDep, ) -> Message: """Refreshes the project lock and returns a status message.""" lock_status = session_service.get_lock_status() @@ -714,7 +713,7 @@ async def post_lock_refresh( }, ) async def get_lock_status( - session_service: ProjectSessionServiceNoExtendDep, + session_service: ProjectSessionServiceDep, ) -> LockStatus: """Returns the lock status and lock file contents if available.""" return session_service.get_lock_status() diff --git a/src/fmu_settings_api/v1/routes/session.py b/src/fmu_settings_api/v1/routes/session.py index dc4bcac..b04ecd9 100644 --- a/src/fmu_settings_api/v1/routes/session.py +++ b/src/fmu_settings_api/v1/routes/session.py @@ -13,18 +13,18 @@ from fmu_settings_api.deps import ( AuthTokenDep, SessionServiceDep, - SessionServiceNoExtendDep, UserFMUDirDep, ) +from fmu_settings_api.deps.session import DestroySessionIfExpiredDep, SessionDep from fmu_settings_api.models import AccessToken, Message, SessionResponse from fmu_settings_api.session import ( ProjectSession, + SessionNotFoundError, add_fmu_project_to_session, - add_rms_project_to_session, create_fmu_session, - destroy_fmu_session, - remove_rms_project_from_session, - session_manager, + get_fmu_session, + get_rms_session_expiration, + refresh_fmu_session, ) from fmu_settings_api.v1.responses import ( CreateSessionResponses, @@ -56,18 +56,18 @@ @router.post( "/", response_model=SessionResponse, - summary="Creates a session for the user", + summary="Creates a new user session or adds a project to the existing one.", description=dedent( """ When creating a session the application will ensure that the user .fmu directory exists by creating it if it does not. - If a session already exists when POSTing to this route, the new session - will preserve the access tokens from the old session. If the old session - had a project .fmu directory, it will also be added to the new session. - After migrating the state, the old session is destroyed. + If a session already exists when POSTing to this route, a project will be added + to the existing session if a project can be found. In case a session with a + project already exists, the existing session will kept as-is and + no new session is created. - If no previous session exists, the application will attempt to find the + When adding a project to the session, the application will attempt to find the nearest project .fmu directory above the current working directory and add it to the session if found. If not found, no project will be associated. @@ -81,17 +81,36 @@ async def post_session( response: Response, auth_token: AuthTokenDep, user_fmu_dir: UserFMUDirDep, + expired_session_dep: DestroySessionIfExpiredDep, fmu_settings_session: Annotated[str | None, Cookie()] = None, ) -> SessionResponse: - """Establishes a user session.""" - old_session = None + """Creates a new user session or adds a project to the existing one.""" if fmu_settings_session: - with contextlib.suppress(Exception): - old_session = await session_manager.get_session( - fmu_settings_session, extend_expiration=False - ) + try: + existing_session = await get_fmu_session(fmu_settings_session) + if isinstance(existing_session, ProjectSession): + response.set_cookie( + key=settings.SESSION_COOKIE_KEY, + value=existing_session.id, + httponly=True, + secure=False, + samesite="lax", + ) + return SessionResponse( + id=existing_session.id, + created_at=existing_session.created_at, + expires_at=existing_session.expires_at, + rms_expires_at=await get_rms_session_expiration( + existing_session.id + ), + last_accessed=existing_session.last_accessed, + ) + session_id = existing_session.id + except SessionNotFoundError: + session_id = await create_fmu_session(user_fmu_dir) + else: + session_id = await create_fmu_session(user_fmu_dir) - session_id = await create_fmu_session(user_fmu_dir) response.set_cookie( key=settings.SESSION_COOKIE_KEY, value=session_id, @@ -100,39 +119,46 @@ async def post_session( samesite="lax", ) - if old_session and fmu_settings_session: - new_session = await session_manager.get_session(session_id) - new_session.access_tokens = old_session.access_tokens - - if isinstance(old_session, ProjectSession): - await add_fmu_project_to_session( - session_id, old_session.project_fmu_directory - ) - - rms_session = old_session.rms_session - if rms_session is not None: - # Transfer existing RMS session to new session - await add_rms_project_to_session( - session_id, - rms_session.executor, - rms_session.project, - ) - await remove_rms_project_from_session( - fmu_settings_session, cleanup=False - ) + with contextlib.suppress(FileNotFoundError, LockError): + path = Path.cwd() + project_fmu_dir = find_nearest_fmu_directory(path) + await add_fmu_project_to_session(session_id, project_fmu_dir) + + session = await get_fmu_session(session_id) + + return SessionResponse( + id=session.id, + created_at=session.created_at, + expires_at=session.expires_at, + rms_expires_at=None, + last_accessed=session.last_accessed, + ) - await destroy_fmu_session(fmu_settings_session) - else: - with contextlib.suppress(FileNotFoundError, LockError): - path = Path.cwd() - project_fmu_dir = find_nearest_fmu_directory(path) - await add_fmu_project_to_session(session_id, project_fmu_dir) - session = await session_manager.get_session(session_id) +@router.patch( + "/", + response_model=SessionResponse, + summary="Refresh a session for the user.", + description=dedent( + """ + Refresh an existing session for the user by extending the + session expiration time. If an RMS session is present, this will + also be refreshed. + """ + ), + responses=GetSessionResponses, +) +async def refresh_session( + response: Response, + session: SessionDep, +) -> SessionResponse: + """Refresh an existing user session.""" + session = await refresh_fmu_session(session.id) return SessionResponse( id=session.id, created_at=session.created_at, expires_at=session.expires_at, + rms_expires_at=await get_rms_session_expiration(session.id), last_accessed=session.last_accessed, ) @@ -195,10 +221,10 @@ async def patch_access_token( responses=GetSessionResponses, ) async def get_session( - session_service: SessionServiceNoExtendDep, + session_service: SessionServiceDep, ) -> SessionResponse: """Returns the current session in a serialisable format.""" - return session_service.get_session_response() + return await session_service.get_session_response() @router.post( diff --git a/tests/conftest.py b/tests/conftest.py index d565553..d249154 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,9 +24,13 @@ from fmu_settings_api.__main__ import app from fmu_settings_api.config import settings -from fmu_settings_api.deps import get_session from fmu_settings_api.models.smda import StratigraphicUnit -from fmu_settings_api.session import SessionManager, add_fmu_project_to_session +from fmu_settings_api.session import ( + SessionManager, + add_fmu_project_to_session, + create_fmu_session, + get_fmu_session, +) @pytest.fixture @@ -213,9 +217,7 @@ def session_manager() -> Generator[SessionManager]: """Mocks the session manager and returns its replacement.""" session_manager = SessionManager() with ( - patch("fmu_settings_api.deps.session.session_manager", session_manager), patch("fmu_settings_api.session.session_manager", session_manager), - patch("fmu_settings_api.v1.routes.session.session_manager", session_manager), ): yield session_manager @@ -226,7 +228,7 @@ async def session_id( ) -> str: """Mocks a valid user .fmu session.""" user_fmu_dir = init_user_fmu_directory() - return await session_manager.create_session(user_fmu_dir) + return await create_fmu_session(user_fmu_dir) @pytest.fixture @@ -240,7 +242,7 @@ async def client_with_session(session_id: str) -> AsyncGenerator[TestClient]: @pytest.fixture async def client_with_project_session(session_id: str) -> AsyncGenerator[TestClient]: """Returns a test client with a valid session.""" - session = await get_session(session_id) + session = await get_fmu_session(session_id) path = session.user_fmu_directory.path.parent.parent # tmp_path fmu_dir = init_fmu_directory(path) @@ -254,7 +256,7 @@ async def client_with_project_session(session_id: str) -> AsyncGenerator[TestCli @pytest.fixture async def client_with_smda_session(session_id: str) -> AsyncGenerator[TestClient]: """Returns a test client with a valid session.""" - session = await get_session(session_id) + session = await get_fmu_session(session_id) path = session.user_fmu_directory.path.parent.parent # tmp_path fmu_dir = init_fmu_directory(path) diff --git a/tests/test_main_config.py b/tests/test_main_config.py index e8dbc94..2bdbec0 100644 --- a/tests/test_main_config.py +++ b/tests/test_main_config.py @@ -134,10 +134,17 @@ def test_app_version_constant() -> None: def test_session_expire_seconds() -> None: """Test SESSION_EXPIRE_SECONDS is set correctly.""" settings = APISettings() - expected_seconds = 1200 # 20 minutes + expected_seconds = 31556926 # 1 year assert expected_seconds == settings.SESSION_EXPIRE_SECONDS +def test_rms_session_expire_seconds() -> None: + """Test SESSION_EXPIRE_SECONDS is set correctly.""" + settings = APISettings() + expected_seconds = 3600 # 60 minutes + assert expected_seconds == settings.RMS_SESSION_EXPIRE_SECONDS + + def test_log_level_accepts_valid_values() -> None: """Test log_level accepts valid logging levels.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index 9769134..ab9c4c1 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -3,7 +3,7 @@ from copy import deepcopy from datetime import UTC, datetime, timedelta from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from fmu.settings._init import init_fmu_directory, init_user_fmu_directory @@ -13,6 +13,7 @@ from fmu_settings_api.config import settings from fmu_settings_api.models.common import AccessToken from fmu_settings_api.session import ( + AccessTokens, ProjectSession, RmsSession, Session, @@ -22,12 +23,17 @@ add_fmu_project_to_session, add_rms_project_to_session, create_fmu_session, - destroy_fmu_session, + destroy_fmu_session_if_expired, + get_fmu_session, + get_rms_session_expiration, + refresh_fmu_session, + refresh_project_lock, release_project_lock, remove_fmu_project_from_session, remove_rms_project_from_session, session_manager, try_acquire_project_lock, + update_fmu_session, ) @@ -43,23 +49,220 @@ def mock_rms_project() -> MagicMock: return MagicMock(close=MagicMock()) -def test_session_manager_init() -> None: - """Tests initialization of the SessionManager.""" - assert session_manager.storage == SessionManager().storage == {} +class TestSessionManagerClass: + """Tests of the internal methods of the SessionManager class.""" + + def test_session_manager_init(self) -> None: + """Tests initialization of the SessionManager.""" + assert session_manager.storage == SessionManager().storage == {} + + async def test_store_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests storing a new session to the storage backend.""" + now = datetime.now(UTC) + session = Session( + id="test_id", + user_fmu_directory=init_user_fmu_directory(), + created_at=now, + expires_at=now, + last_accessed=now, + access_tokens=AccessTokens(fmu_settings=SecretStr("some_secret")), + ) + session_id = session.id + await session_manager._store_session(session_id, session) + assert session_manager.storage[session_id] == session + + async def test_retrieve_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests retrieving a session from the storage backend.""" + now = datetime.now(UTC) + session_id = "test_id" + session = Session( + id=session_id, + user_fmu_directory=init_user_fmu_directory(), + created_at=now, + expires_at=now, + last_accessed=now, + access_tokens=AccessTokens(fmu_settings=SecretStr("some_secret")), + ) + await session_manager._store_session(session_id, session) + retrieved_session = await session_manager._retrieve_session(session_id) + + assert retrieved_session == session_manager.storage[session_id] + assert retrieved_session == session + + async def test_update_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests updating a session.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + session = await session_manager.get_session(session_id) + expires_at_old = session.expires_at + expires_at_new = expires_at_old + timedelta(seconds=5) + session.expires_at = expires_at_new + await session_manager.update_session(session.id, session) -async def test_create_session( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests creating a new session.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - assert session_id in session_manager.storage - assert session_manager.storage[session_id].user_fmu_directory == user_fmu_dir - assert len(session_manager.storage) == 1 + assert session_manager.storage[session_id].expires_at == expires_at_new + assert session_manager.storage[session_id] == session + + async def test_destroy_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests destroying a session.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + await session_manager.destroy_session(session_id) + assert session_id not in session_manager.storage + assert len(session_manager.storage) == 0 + + async def test_destroy_session_releases_project_lock( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests that destroying a session with a project releases the project lock.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + project_fmu_dir = init_fmu_directory(project_path) + + mock_lock = Mock() + project_fmu_dir._lock = mock_lock + + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project_fmu_dir) + mock_lock.acquire.assert_called_once() + + await session_manager.destroy_session(session_id) + mock_lock.release.assert_called_once() + + async def test_destroy_session_handles_lock_release_exceptions( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests that session destruction handles lock release exceptions gracefully.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + project_fmu_dir = init_fmu_directory(project_path) + + mock_lock = Mock() + mock_lock.release.side_effect = Exception("Lock release failed") + project_fmu_dir._lock = mock_lock + + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project_fmu_dir) + + await session_manager.destroy_session(session_id) + + assert session_id not in session_manager.storage + + async def test_destroy_session_closes_rms_project( + self, + session_manager: SessionManager, + tmp_path_mocked_home: Path, + mock_rms_executor: MagicMock, + mock_rms_project: MagicMock, + ) -> None: + """Test that destroying a session closes the RMS project.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + project_fmu_dir = init_fmu_directory(project_path) + + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project_fmu_dir) + await add_rms_project_to_session( + session_id, mock_rms_executor, mock_rms_project + ) + + await session_manager.destroy_session(session_id) + + mock_rms_project.close.assert_called_once() + mock_rms_executor.shutdown.assert_called_once() + + async def test_create_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests creating a new session.""" + user_fmu_dir = init_user_fmu_directory() + mocked_now = datetime.now(UTC) + expire_seconds = 5 + + with patch("fmu_settings_api.session.datetime") as datetime_mock: + datetime_mock.now.return_value = mocked_now + session_id = await session_manager.create_session( + user_fmu_dir, expire_seconds + ) + + assert session_id in session_manager.storage + assert session_manager.storage[session_id].user_fmu_directory == user_fmu_dir + assert session_manager.storage[session_id].expires_at == mocked_now + timedelta( + seconds=expire_seconds + ) + assert len(session_manager.storage) == 1 + + async def test_create_session_uses_default_expire_seconds( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Creating a new session will use the default expiration when not specified.""" + user_fmu_dir = init_user_fmu_directory() + mocked_now = datetime.now(UTC) + + with patch("fmu_settings_api.session.datetime") as datetime_mock: + datetime_mock.now.return_value = mocked_now + session_id = await session_manager.create_session(user_fmu_dir) + + assert session_id in session_manager.storage + assert session_manager.storage[session_id].user_fmu_directory == user_fmu_dir + assert session_manager.storage[session_id].expires_at == mocked_now + timedelta( + seconds=settings.SESSION_EXPIRE_SECONDS + ) + assert len(session_manager.storage) == 1 + + async def test_get_existing_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests getting an existing session.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + session = await session_manager.get_session(session_id) + assert session == session_manager.storage[session_id] + assert len(session_manager.storage) == 1 + + async def test_get_non_existing_session( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests getting a non existing session raises SessionNotFoundError.""" + user_fmu_dir = init_user_fmu_directory() + await session_manager.create_session(user_fmu_dir) + with pytest.raises(SessionNotFoundError, match="No active session found"): + await session_manager.get_session("no") + assert len(session_manager.storage) == 1 + + async def test_get_existing_session_updates_last_accessed( + self, session_manager: SessionManager, tmp_path_mocked_home: Path + ) -> None: + """Tests getting an existing session updates its last accessed.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await session_manager.create_session(user_fmu_dir) + orig_session = deepcopy(session_manager.storage[session_id]) + session = await session_manager.get_session(session_id) + assert session is not None + assert orig_session.last_accessed < session.last_accessed -async def test_create_session_wrapper( +# Test wrapper functions + + +async def test_create_fmu_session( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: """Tests creating a new session with the wrapper.""" @@ -71,88 +274,382 @@ async def test_create_session_wrapper( assert len(session_manager.storage) == 1 -async def test_get_non_existing_session( +async def test_get_fmu_session( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session.""" + """Tests getting a session with the wrapper.""" user_fmu_dir = init_user_fmu_directory() - await session_manager.create_session(user_fmu_dir) - with pytest.raises(SessionNotFoundError, match="No active session found"): - await session_manager.get_session("no") + session_id = await create_fmu_session(user_fmu_dir) + session = await get_fmu_session(session_id) + assert session == session_manager.storage[session_id] assert len(session_manager.storage) == 1 -async def test_get_existing_session( +async def test_update_fmu_session( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session.""" + """Tests updating a session with the wrapper.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - session = await session_manager.get_session(session_id) - assert session == session_manager.storage[session_id] - assert len(session_manager.storage) == 1 + session_id = await create_fmu_session(user_fmu_dir) + session = await get_fmu_session(session_id) + expires_at_old = session.expires_at + expires_at_new = expires_at_old + timedelta(seconds=5) + session.expires_at = expires_at_new + + await update_fmu_session(session) + updated_session = await get_fmu_session(session_id) + + assert updated_session.expires_at == expires_at_new + assert updated_session == session_manager.storage[session_id] + + +async def test_destroy_fmu_session_if_expired_with_expired_session() -> None: + """Tests that the session is destroyed and the RMS session is removed. + + Scnenario: Both the user session and the RMS session has expired. + """ + mocked_session = AsyncMock(spec=ProjectSession) + mocked_rms_session = MagicMock() + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_session.rms_session = mocked_rms_session + expired_timestamp = datetime.now(UTC) + + # Expire both sessions + mocked_session.expires_at = expired_timestamp + mocked_session.rms_session.expires_at = expired_timestamp + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_destroy_session = AsyncMock(return_value=None) + mocked_session_manager.get_session.side_effect = mocked_get_session + mocked_session_manager.destroy_session.side_effect = mocked_destroy_session + with patch( + "fmu_settings_api.session.remove_rms_project_from_session", + new_callable=AsyncMock, + ) as mock_remove_rms_project: + mock_remove_rms_project.return_value = None + await destroy_fmu_session_if_expired(mocked_session.id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + mocked_session_manager.destroy_session.assert_called_once_with( + mocked_session_id + ) + mock_remove_rms_project.assert_called_once_with(mocked_session_id) + + +async def test_destroy_fmu_session_if_expired_with_only_rms_session_expired() -> None: + """Tests that only the RMS session is removed if only RMS session has expired. + + Scnenario: User session is valid, RMS session has expired. + """ + mocked_session = AsyncMock(spec=ProjectSession) + mocked_rms_session = MagicMock() + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_session.rms_session = mocked_rms_session + + # Expire only the RMS session + active_datetime = datetime.now(UTC) + timedelta(seconds=60) + mocked_session.expires_at = active_datetime + expired_datetime = datetime.now(UTC) + mocked_session.rms_session.expires_at = expired_datetime + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_destroy_session = AsyncMock(return_value=None) + mocked_session_manager.get_session.side_effect = mocked_get_session + mocked_session_manager.destroy_session.side_effect = mocked_destroy_session + + with patch( + "fmu_settings_api.session.remove_rms_project_from_session", + new_callable=AsyncMock, + ) as mock_remove_rms_project: + mock_remove_rms_project.return_value = None + await destroy_fmu_session_if_expired(mocked_session.id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + mock_remove_rms_project.assert_called_once_with(mocked_session_id) + mocked_session_manager.destroy_session.assert_not_called() + + +async def test_destroy_fmu_session_if_expired_when_session_is_valid() -> None: + """Tests that no session is destroyed or removed when both sessions are active. + + Scnenario: Both user session and RMS session is active. + """ + mocked_session = AsyncMock(spec=ProjectSession) + mocked_rms_session = MagicMock() + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_session.rms_session = mocked_rms_session + + # Set both sessions as active + active_datetime = datetime.now(UTC) + timedelta(seconds=60) + mocked_session.expires_at = active_datetime + mocked_session.rms_session.expires_at = active_datetime + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_destroy_session = AsyncMock(return_value=None) + mocked_session_manager.get_session.side_effect = mocked_get_session + mocked_session_manager.destroy_session.side_effect = mocked_destroy_session + + with patch( + "fmu_settings_api.session.remove_rms_project_from_session", + new_callable=AsyncMock, + ) as mock_remove_rms_project: + mock_remove_rms_project.return_value = None + await destroy_fmu_session_if_expired(mocked_session.id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + mock_remove_rms_project.assert_not_called() + mocked_session_manager.destroy_session.assert_not_called() + + +async def test_refresh_fmu_session_with_rms_project() -> None: + """Tests that both the user session and RMS session are refreshed.""" + mocked_session = AsyncMock(spec=ProjectSession) + mocked_rms_session = MagicMock() + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_session.rms_session = mocked_rms_session + mocked_now = datetime.now(UTC) + mocked_session.expires_at = mocked_now + mocked_session.rms_session.expires_at = mocked_now + mocked_session.last_accessed = mocked_now + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_update_session = AsyncMock(return_value=None) + mocked_session_manager.get_session.side_effect = mocked_get_session + mocked_session_manager.update_session.side_effect = mocked_update_session + + await refresh_fmu_session(mocked_session.id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + mocked_session_manager.update_session.assert_called_once_with( + mocked_session_id, mocked_session + ) + updated_session_id = mocked_session_manager.update_session.call_args[0][0] + updated_session = mocked_session_manager.update_session.call_args[0][1] + assert updated_session_id == mocked_session_id + assert updated_session == mocked_session + assert updated_session.id == mocked_session_id + assert updated_session.expires_at > mocked_now + assert updated_session.rms_session.expires_at > mocked_now + + +async def test_refresh_fmu_session_without_rms_project() -> None: + """Tests that only the user session is refreshed.""" + mocked_session = AsyncMock(spec=ProjectSession) + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_now = datetime.now(UTC) + mocked_session.expires_at = mocked_now + mocked_session.rms_session = None + mocked_session.last_accessed = mocked_now + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_update_session = AsyncMock(return_value=None) + mocked_session_manager.get_session.side_effect = mocked_get_session + mocked_session_manager.update_session.side_effect = mocked_update_session + + await refresh_fmu_session(mocked_session.id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + mocked_session_manager.update_session.assert_called_once() + updated_session = mocked_session_manager.update_session.call_args[0][1] + assert updated_session == mocked_session + assert updated_session.id == mocked_session_id + assert updated_session.expires_at > mocked_now + assert updated_session.rms_session is None -async def test_get_existing_session_expiration( +async def test_add_fmu_project_to_session_acquires_lock( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session expires.""" + """Tests that adding an FMU project to a session acquires the lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - orig_session = session_manager.storage[session_id] - expiration_duration = timedelta(seconds=settings.SESSION_EXPIRE_SECONDS) - assert orig_session.created_at + expiration_duration == orig_session.expires_at + session_id = await create_fmu_session(user_fmu_dir) + + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + project_fmu_dir = init_fmu_directory(project_path) - # Pretend it expired a second ago. - orig_session.expires_at = datetime.now(UTC) - timedelta(seconds=1) - with pytest.raises(SessionNotFoundError, match="Invalid or expired session"): - assert await session_manager.get_session(session_id) - # It should also be destroyed. - assert session_id not in session_manager.storage - assert len(session_manager.storage) == 0 + mock_lock = Mock() + project_fmu_dir._lock = mock_lock + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project_fmu_dir) -async def test_get_existing_session_updates_last_accessed( + mock_lock.acquire.assert_called_once() + + +async def test_add_fmu_project_to_session_releases_previous_lock( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session updates its last accessed.""" + """Tests that adding a new project releases the previous project's lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - orig_session = deepcopy(session_manager.storage[session_id]) - session = await session_manager.get_session(session_id) - assert session is not None - assert orig_session.last_accessed < session.last_accessed + session_id = await create_fmu_session(user_fmu_dir) + + project1_path = tmp_path_mocked_home / "test_project1" + project1_path.mkdir() + project1_fmu_dir = init_fmu_directory(project1_path) + + project2_path = tmp_path_mocked_home / "test_project2" + project2_path.mkdir() + project2_fmu_dir = init_fmu_directory(project2_path) + + mock_lock1 = Mock() + mock_lock2 = Mock() + project1_fmu_dir._lock = mock_lock1 + project2_fmu_dir._lock = mock_lock2 + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project1_fmu_dir) + mock_lock1.acquire.assert_called_once() + + await add_fmu_project_to_session(session_id, project2_fmu_dir) + mock_lock1.release.assert_called_once() + mock_lock2.acquire.assert_called_once() -async def test_get_existing_session_updates_expires_at( + +async def test_add_fmu_project_to_session_handles_previous_lock_release_error( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session updates its expiration.""" + """Tests handling exception when releasing previous lock.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await create_fmu_session(user_fmu_dir) + + project1_path = tmp_path_mocked_home / "test_project1" + project1_path.mkdir() + project1_fmu_dir = init_fmu_directory(project1_path) + + mock_lock1 = Mock() + project1_fmu_dir._lock = mock_lock1 + + project2_path = tmp_path_mocked_home / "test_project2" + project2_path.mkdir() + project2_fmu_dir = init_fmu_directory(project2_path) + + mock_lock2 = Mock() + project2_fmu_dir._lock = mock_lock2 + + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project1_fmu_dir) + + mock_lock1.release.side_effect = Exception("Failed to release lock") + + project_session = await add_fmu_project_to_session(session_id, project2_fmu_dir) + + assert project_session.project_fmu_directory == project2_fmu_dir + assert project_session.lock_errors.release == "Failed to release lock" + + mock_lock1.release.assert_called_once() + mock_lock2.acquire.assert_called_once() + + +async def test_add_fmu_project_to_session_closes_existing_rms( + session_manager: SessionManager, + tmp_path_mocked_home: Path, + mock_rms_executor: MagicMock, + mock_rms_project: MagicMock, +) -> None: + """Test that switching projects closes any existing RMS project.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - orig_session = deepcopy(session_manager.storage[session_id]) - session = await session_manager.get_session(session_id) - assert session is not None - assert ( - orig_session.last_accessed + timedelta(seconds=settings.SESSION_EXPIRE_SECONDS) - < session.expires_at - ) - assert orig_session.expires_at < session.expires_at + session_id = await create_fmu_session(user_fmu_dir) + project1_path = tmp_path_mocked_home / "test_project1" + project1_path.mkdir() + project1_fmu_dir = init_fmu_directory(project1_path) -async def test_get_existing_session_does_not_update_expires_at( + project2_path = tmp_path_mocked_home / "test_project2" + project2_path.mkdir() + project2_fmu_dir = init_fmu_directory(project2_path) + + with patch("fmu_settings_api.session.session_manager", session_manager): + await add_fmu_project_to_session(session_id, project1_fmu_dir) + + session = await get_fmu_session(session_id) + rms_session_expires_at = datetime.now(UTC) + timedelta(seconds=5) + + assert isinstance(session, ProjectSession) + session.rms_session = RmsSession( + mock_rms_executor, mock_rms_project, rms_session_expires_at + ) + + project_session = await add_fmu_project_to_session(session_id, project2_fmu_dir) + + original_session = await get_fmu_session(session_id) + + mock_rms_project.close.assert_called_once() + mock_rms_executor.shutdown.assert_called_once() + assert project_session == original_session + assert project_session.project_fmu_directory == project2_fmu_dir + assert project_session.rms_session is None + + +async def test_add_fmu_project_to_session_handles_lock_error_gracefully( + session_manager: SessionManager, tmp_path_mocked_home: Path +) -> None: + """Tests that LockError is gracefully handled in add_fmu_project_to_session.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await create_fmu_session(user_fmu_dir) + + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + project_fmu_dir = init_fmu_directory(project_path) + + mock_lock = Mock() + mock_lock.acquire.side_effect = LockError("Project is locked by another process") + mock_lock.is_acquired.return_value = False + project_fmu_dir._lock = mock_lock + + with patch("fmu_settings_api.session.session_manager", session_manager): + project_session = await add_fmu_project_to_session(session_id, project_fmu_dir) + + assert project_session is not None + assert project_session.project_fmu_directory == project_fmu_dir + + mock_lock.acquire.assert_called_once() + assert not project_session.project_fmu_directory._lock.is_acquired() + + +async def test_add_access_token_to_session_with_valid_token( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests getting an existing session doesn't update expiration if not extended.""" + """Tests adding a valid access token to a session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - orig_session = deepcopy(session_manager.storage[session_id]) - session = await session_manager.get_session(session_id, extend_expiration=False) - assert session is not None - # Last accessed changed - assert orig_session.last_accessed < session.last_accessed - # But same expiration - assert orig_session.expires_at == session.expires_at + session_id = await create_fmu_session(user_fmu_dir) + + session = await get_fmu_session(session_id) + assert session.access_tokens.smda_api is None + + token = AccessToken(id="smda_api", key=SecretStr("secret")) + await add_access_token_to_session(session_id, token) + + session = await get_fmu_session(session_id) + assert session.access_tokens.smda_api is not None + + # Assert obfuscated + assert str(session.access_tokens.smda_api) == "*" * 10 + + +async def test_add_access_token_to_session_with_invalid_token( + session_manager: SessionManager, tmp_path_mocked_home: Path +) -> None: + """Tests adding an invalid access token to a session.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await create_fmu_session(user_fmu_dir) + + session = await get_fmu_session(session_id) + assert session.access_tokens.smda_api is None + + token = AccessToken(id="foo", key=SecretStr("secret")) + with pytest.raises(ValueError, match="Invalid access token id"): + await add_access_token_to_session(session_id, token) async def test_try_acquire_project_lock_acquires_when_not_held( @@ -160,7 +657,7 @@ async def test_try_acquire_project_lock_acquires_when_not_held( ) -> None: """Tests that try_acquire_project_lock acquires the lock when not already held.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_acquire_project" project_path.mkdir() @@ -187,7 +684,7 @@ async def test_try_acquire_project_lock_records_acquire_error( ) -> None: """Tests that lock acquire failures are captured by try_acquire_project_lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_acquire_error_project" project_path.mkdir() @@ -214,7 +711,7 @@ async def test_try_acquire_project_lock_requires_project_session( ) -> None: """Tests that try_acquire_project_lock requires a project-scoped session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with ( patch("fmu_settings_api.session.session_manager", session_manager), @@ -228,7 +725,7 @@ async def test_try_acquire_project_lock_handles_is_acquired_error( ) -> None: """Tests that try_acquire_project_lock tolerates lock status errors.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_status_error_project" project_path.mkdir() @@ -256,7 +753,7 @@ async def test_release_project_lock_releases_when_held( ) -> None: """Tests that release_project_lock releases the lock when held.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_project" project_path.mkdir() @@ -283,7 +780,7 @@ async def test_release_project_lock_requires_project_session( ) -> None: """Tests that release_project_lock requires a project-scoped session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with ( patch("fmu_settings_api.session.session_manager", session_manager), @@ -297,7 +794,7 @@ async def test_release_project_lock_records_release_error( ) -> None: """Tests that lock release failures are captured by release_project_lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_error_project" project_path.mkdir() @@ -324,7 +821,7 @@ async def test_release_project_lock_skips_when_not_held( ) -> None: """Tests that release_project_lock does not release when lock is not held.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_not_held_project" project_path.mkdir() @@ -346,156 +843,107 @@ async def test_release_project_lock_skips_when_not_held( assert result.lock_errors.release is None -async def test_destroy_fmu_session( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests destroying a session.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - with patch("fmu_settings_api.session.session_manager", session_manager): - await destroy_fmu_session(session_id) - assert session_id not in session_manager.storage - assert len(session_manager.storage) == 0 - - -async def test_add_valid_access_token_to_session( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests adding an access token to a session.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - session = await session_manager.get_session(session_id) - assert session.access_tokens.smda_api is None - - token = AccessToken(id="smda_api", key=SecretStr("secret")) - await add_access_token_to_session(session_id, token) - - session = await session_manager.get_session(session_id) - assert session.access_tokens.smda_api is not None - - # Assert obfuscated - assert str(session.access_tokens.smda_api) == "*" * 10 - - -async def test_add_invalid_access_token_to_session( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests adding an invalid access token to a session.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - session = await session_manager.get_session(session_id) - assert session.access_tokens.smda_api is None - - token = AccessToken(id="foo", key=SecretStr("secret")) - with pytest.raises(ValueError, match="Invalid access token id"): - await add_access_token_to_session(session_id, token) - - -async def test_add_fmu_project_to_session_acquires_lock( +async def test_refresh_project_lock_refreshes_when_held( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that adding an FMU project to a session acquires the lock.""" + """Tests that refresh_project_lock refreshes the lock when held.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) - project_path = tmp_path_mocked_home / "test_project" + project_path = tmp_path_mocked_home / "lock_refresh_project" project_path.mkdir() project_fmu_dir = init_fmu_directory(project_path) mock_lock = Mock() + mock_lock.is_acquired.return_value = True + mock_lock.refresh = Mock() project_fmu_dir._lock = mock_lock with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) + mock_lock.reset_mock() + result = await refresh_project_lock(session_id) - mock_lock.acquire.assert_called_once() + assert isinstance(result, ProjectSession) + mock_lock.is_acquired.assert_called_once_with() + mock_lock.refresh.assert_called_once_with() + assert result.lock_errors.refresh is None -async def test_add_fmu_project_to_session_releases_previous_lock( +async def test_refresh_project_lock_requires_project_session( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that adding a new project releases the previous project's lock.""" + """Tests that refresh_project_lock requires a project-scoped session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) - - project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) - - mock_lock1 = Mock() - mock_lock2 = Mock() - project1_fmu_dir._lock = mock_lock1 - project2_fmu_dir._lock = mock_lock2 + session_id = await create_fmu_session(user_fmu_dir) - with patch("fmu_settings_api.session.session_manager", session_manager): - await add_fmu_project_to_session(session_id, project1_fmu_dir) - mock_lock1.acquire.assert_called_once() - - await add_fmu_project_to_session(session_id, project2_fmu_dir) - mock_lock1.release.assert_called_once() - mock_lock2.acquire.assert_called_once() + with ( + patch("fmu_settings_api.session.session_manager", session_manager), + pytest.raises(SessionNotFoundError, match="No FMU project directory open"), + ): + await refresh_project_lock(session_id) -async def test_remove_fmu_project_from_session_releases_lock( +async def test_refresh_project_lock_records_refresh_error( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that removing an FMU project from a session releases the lock.""" + """Tests that lock refresh failures are captured by refresh_project_lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) - project_path = tmp_path_mocked_home / "test_project" + project_path = tmp_path_mocked_home / "lock_refresh_error_project" project_path.mkdir() project_fmu_dir = init_fmu_directory(project_path) mock_lock = Mock() + mock_lock.is_acquired.return_value = True + mock_lock.refresh = Mock(side_effect=LockError("Refresh failed")) project_fmu_dir._lock = mock_lock with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) - mock_lock.acquire.assert_called_once() + mock_lock.reset_mock() + result = await refresh_project_lock(session_id) - await remove_fmu_project_from_session(session_id) - mock_lock.release.assert_called_once() + assert isinstance(result, ProjectSession) + mock_lock.is_acquired.assert_called_once_with() + mock_lock.refresh.assert_called_once_with() + assert result.lock_errors.refresh == "Refresh failed" -async def test_remove_fmu_project_from_session_handles_lock_release_exception( +async def test_refresh_project_lock_skips_when_not_held( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that removing an FMU project handles lock release exceptions gracefully.""" + """Tests that refresh_project_lock does not refresh when lock is not held.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) - project_path = tmp_path_mocked_home / "test_project" + project_path = tmp_path_mocked_home / "lock_refresh_not_held_project" project_path.mkdir() project_fmu_dir = init_fmu_directory(project_path) mock_lock = Mock() - mock_lock.release.side_effect = Exception("Lock release failed") + mock_lock.is_acquired.return_value = False + mock_lock.refresh = Mock() project_fmu_dir._lock = mock_lock with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) - mock_lock.acquire.assert_called_once() - - result = await remove_fmu_project_from_session(session_id) - mock_lock.release.assert_called_once() + mock_lock.reset_mock() + result = await refresh_project_lock(session_id) - assert isinstance(result, Session) - assert result.id == session_id + assert isinstance(result, ProjectSession) + mock_lock.is_acquired.assert_called_once_with() + mock_lock.refresh.assert_not_called() + assert result.lock_errors.refresh is None -async def test_destroy_session_releases_project_lock( +async def test_remove_fmu_project_from_session_releases_lock( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that destroying a session with a project releases the project lock.""" + """Tests that removing an FMU project from a session releases the lock.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -508,16 +956,16 @@ async def test_destroy_session_releases_project_lock( await add_fmu_project_to_session(session_id, project_fmu_dir) mock_lock.acquire.assert_called_once() - await session_manager.destroy_session(session_id) + await remove_fmu_project_from_session(session_id) mock_lock.release.assert_called_once() -async def test_destroy_session_handles_lock_release_exceptions( +async def test_remove_fmu_project_from_session_handles_lock_release_exception( session_manager: SessionManager, tmp_path_mocked_home: Path ) -> None: - """Tests that session destruction handles lock release exceptions gracefully.""" + """Tests that removing an FMU project handles lock release exceptions gracefully.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -529,71 +977,13 @@ async def test_destroy_session_handles_lock_release_exceptions( with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) - - await session_manager.destroy_session(session_id) - - assert session_id not in session_manager.storage - - -async def test_lock_error_gracefully_handled_in_add_fmu_project_to_session( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests that LockError is gracefully handled in add_fmu_project_to_session.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - - mock_lock = Mock() - mock_lock.acquire.side_effect = LockError("Project is locked by another process") - mock_lock.is_acquired.return_value = False - project_fmu_dir._lock = mock_lock - - with patch("fmu_settings_api.session.session_manager", session_manager): - project_session = await add_fmu_project_to_session(session_id, project_fmu_dir) - - assert project_session is not None - assert project_session.project_fmu_directory == project_fmu_dir - mock_lock.acquire.assert_called_once() - assert not project_session.project_fmu_directory._lock.is_acquired() - -async def test_add_fmu_project_to_session_handles_previous_lock_release_error( - session_manager: SessionManager, tmp_path_mocked_home: Path -) -> None: - """Tests handling exception when releasing previous lock.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) - - mock_lock1 = Mock() - project1_fmu_dir._lock = mock_lock1 - - project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) - - mock_lock2 = Mock() - project2_fmu_dir._lock = mock_lock2 - - with patch("fmu_settings_api.session.session_manager", session_manager): - await add_fmu_project_to_session(session_id, project1_fmu_dir) - - mock_lock1.release.side_effect = Exception("Failed to release lock") - - project_session = await add_fmu_project_to_session(session_id, project2_fmu_dir) - - assert project_session.project_fmu_directory == project2_fmu_dir - assert project_session.lock_errors.release == "Failed to release lock" + result = await remove_fmu_project_from_session(session_id) + mock_lock.release.assert_called_once() - mock_lock1.release.assert_called_once() - mock_lock2.acquire.assert_called_once() + assert isinstance(result, Session) + assert result.id == session_id async def test_remove_fmu_project_from_session_with_regular_session( @@ -601,12 +991,12 @@ async def test_remove_fmu_project_from_session_with_regular_session( ) -> None: """Tests remove_fmu_project_from_session when session is not a ProjectSession.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with patch("fmu_settings_api.session.session_manager", session_manager): result_session = await remove_fmu_project_from_session(session_id) - original_session = await session_manager.get_session(session_id) + original_session = await get_fmu_session(session_id) assert result_session == original_session @@ -619,7 +1009,7 @@ async def test_add_rms_project_to_session_success( ) -> None: """Test adding an RMS project to a valid project session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -632,7 +1022,7 @@ async def test_add_rms_project_to_session_success( session_id, mock_rms_executor, mock_rms_project ) - original_session = await session_manager.get_session(session_id) + original_session = await get_fmu_session(session_id) assert result_session == original_session assert result_session.rms_session is not None @@ -648,7 +1038,7 @@ async def test_add_rms_project_to_session_no_project_session( ) -> None: """Test adding an RMS project when no FMU project is open in session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with ( patch("fmu_settings_api.session.session_manager", session_manager), @@ -667,7 +1057,7 @@ async def test_add_rms_project_to_session_closes_existing( ) -> None: """Test that adding a new RMS project closes the existing one.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -675,21 +1065,25 @@ async def test_add_rms_project_to_session_closes_existing( mock_rms_executor_existing = MagicMock(shutdown=MagicMock()) mock_rms_project_existing = MagicMock(close=MagicMock()) + rms_session_expires_at = datetime.now(UTC) + timedelta(seconds=5) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) + assert isinstance(session, ProjectSession) session.rms_session = RmsSession( - mock_rms_executor_existing, mock_rms_project_existing + mock_rms_executor_existing, + mock_rms_project_existing, + rms_session_expires_at, ) result_session = await add_rms_project_to_session( session_id, mock_rms_executor, mock_rms_project ) - original_session = await session_manager.get_session(session_id) + original_session = await get_fmu_session(session_id) mock_rms_project_existing.close.assert_called_once() mock_rms_executor_existing.shutdown.assert_called_once() @@ -697,42 +1091,7 @@ async def test_add_rms_project_to_session_closes_existing( assert result_session.rms_session is not None assert result_session.rms_session.executor == mock_rms_executor assert result_session.rms_session.project == mock_rms_project - - -async def test_add_fmu_project_to_session_closes_existing_rms( - session_manager: SessionManager, - tmp_path_mocked_home: Path, - mock_rms_executor: MagicMock, - mock_rms_project: MagicMock, -) -> None: - """Test that switching projects closes any existing RMS project.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) - - project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) - - with patch("fmu_settings_api.session.session_manager", session_manager): - await add_fmu_project_to_session(session_id, project1_fmu_dir) - - session = await session_manager.get_session(session_id) - assert isinstance(session, ProjectSession) - session.rms_session = RmsSession(mock_rms_executor, mock_rms_project) - - project_session = await add_fmu_project_to_session(session_id, project2_fmu_dir) - - original_session = await session_manager.get_session(session_id) - - mock_rms_project.close.assert_called_once() - mock_rms_executor.shutdown.assert_called_once() - assert project_session == original_session - assert project_session.project_fmu_directory == project2_fmu_dir - assert project_session.rms_session is None + assert result_session.rms_session.expires_at > rms_session_expires_at async def test_remove_rms_project_from_session_success( @@ -743,7 +1102,7 @@ async def test_remove_rms_project_from_session_success( ) -> None: """Test removing an RMS project from a session.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -756,7 +1115,7 @@ async def test_remove_rms_project_from_session_success( ) result_session = await remove_rms_project_from_session(session_id) - original_session = await session_manager.get_session(session_id) + original_session = await get_fmu_session(session_id) assert result_session.rms_session is None assert isinstance(original_session, ProjectSession) @@ -769,7 +1128,7 @@ async def test_remove_rms_project_from_session_no_project_session( ) -> None: """Test removing an RMS project when no FMU project is open.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with ( patch("fmu_settings_api.session.session_manager", session_manager), @@ -786,7 +1145,7 @@ async def test_remove_rms_project_from_session_closes_project( ) -> None: """Test that removing an RMS project calls close() on it.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -799,7 +1158,7 @@ async def test_remove_rms_project_from_session_closes_project( ) result_session = await remove_rms_project_from_session(session_id) - original_session = await session_manager.get_session(session_id) + original_session = await get_fmu_session(session_id) mock_rms_project.close.assert_called_once() mock_rms_executor.shutdown.assert_called_once() @@ -807,15 +1166,15 @@ async def test_remove_rms_project_from_session_closes_project( assert result_session.id == session_id -async def test_destroy_session_closes_rms_project( +async def test_remove_fmu_project_from_session_closes_rms_project( session_manager: SessionManager, tmp_path_mocked_home: Path, mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: - """Test that destroying a session closes the RMS project.""" + """Test that closing the FMU project also closes the RMS project.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -827,33 +1186,59 @@ async def test_destroy_session_closes_rms_project( session_id, mock_rms_executor, mock_rms_project ) - await session_manager.destroy_session(session_id) + await remove_fmu_project_from_session(session_id) mock_rms_project.close.assert_called_once() mock_rms_executor.shutdown.assert_called_once() -async def test_remove_fmu_project_from_session_closes_rms_project( - session_manager: SessionManager, - tmp_path_mocked_home: Path, - mock_rms_executor: MagicMock, - mock_rms_project: MagicMock, -) -> None: - """Test that closing the FMU project also closes the RMS project.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) +async def test_get_rms_session_expiration() -> None: + """Tests getting the RMS session expiration time from a session.""" + mocked_session = AsyncMock(spec=ProjectSession) + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_rms_session = MagicMock() + mocked_rms_session.expires_at = datetime.now(UTC) + mocked_session.rms_session = mocked_rms_session - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_session_manager.get_session.side_effect = mocked_get_session - with patch("fmu_settings_api.session.session_manager", session_manager): - await add_fmu_project_to_session(session_id, project_fmu_dir) - await add_rms_project_to_session( - session_id, mock_rms_executor, mock_rms_project - ) + rms_session_expiration = await get_rms_session_expiration(mocked_session_id) - await remove_fmu_project_from_session(session_id) + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + assert rms_session_expiration == mocked_session.rms_session.expires_at - mock_rms_project.close.assert_called_once() - mock_rms_executor.shutdown.assert_called_once() + +async def test_get_rms_session_expiration_when_no_rms_session_present() -> None: + """Tests that None is returned when no RMS session present in the session.""" + mocked_session = AsyncMock(spec=ProjectSession) + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + mocked_session.rms_session = None + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_session_manager.get_session.side_effect = mocked_get_session + + rms_session_expiration = await get_rms_session_expiration(mocked_session_id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + assert rms_session_expiration is None + + +async def test_get_rms_session_expiration_requires_project_session() -> None: + """Tests that None is returned when the session is not a project session.""" + mocked_session = AsyncMock(spec=Session) + mocked_session_id = "mocked_id" + mocked_session.id = mocked_session_id + + with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: + mocked_get_session = AsyncMock(return_value=mocked_session) + mocked_session_manager.get_session.side_effect = mocked_get_session + + rms_session_expiration = await get_rms_session_expiration(mocked_session_id) + + mocked_session_manager.get_session.assert_called_with(mocked_session_id) + assert rms_session_expiration is None diff --git a/tests/test_v1/test_deps.py b/tests/test_v1/test_deps.py index cafb293..aa1b7ab 100644 --- a/tests/test_v1/test_deps.py +++ b/tests/test_v1/test_deps.py @@ -22,13 +22,9 @@ from fmu_settings_api.deps.project import get_project_service from fmu_settings_api.deps.session import ( get_project_session, - get_project_session_no_extend, get_project_session_service, - get_project_session_service_no_extend, get_session, - get_session_no_extend, get_session_service, - get_session_service_no_extend, ) from fmu_settings_api.deps.smda import get_project_smda_interface from fmu_settings_api.deps.user_fmu import ensure_user_fmu_directory @@ -42,6 +38,8 @@ Session, SessionManager, add_fmu_project_to_session, + create_fmu_session, + get_fmu_session, ) ROUTE = "/api/v1/health" @@ -54,22 +52,19 @@ async def test_get_session_dep( with pytest.raises(HTTPException, match="401: No active session found"): await get_session(None) - with pytest.raises(HTTPException, match="401: Invalid or expired session"): - await get_session(Cookie(default=uuid4())) - user_fmu_dir = init_user_fmu_directory() - valid_session = await session_manager.create_session(user_fmu_dir) - session = await get_session(valid_session) + valid_session = await create_fmu_session(user_fmu_dir) + session = await get_session(None, valid_session) assert session.user_fmu_directory.path == user_fmu_dir.path with ( patch( - "fmu_settings_api.deps.session.session_manager.get_session", + "fmu_settings_api.deps.session.get_fmu_session", side_effect=Exception("foo"), ), pytest.raises(HTTPException, match="500: Session error: foo"), ): - await get_session(Cookie(default=object)) + await get_session(None, Cookie(default=object)) def test_get_session_dep_from_request( @@ -224,7 +219,7 @@ async def test_check_write_permissions_project_not_acquired( ) -> None: """Test that check_write_permissions raises HTTPException when not acquired.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -249,7 +244,7 @@ async def test_check_write_permissions_not_locked( ) -> None: """Test that check_write_permissions raises 423 when project is not locked.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -274,7 +269,7 @@ async def test_check_write_permissions_permission_error( ) -> None: """Test that check_write_permissions raises 403 on PermissionError.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -298,7 +293,7 @@ async def test_check_write_permissions_file_not_found_error( ) -> None: """Test that check_write_permissions raises 423 on FileNotFoundError.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -318,89 +313,13 @@ async def test_check_write_permissions_file_not_found_error( assert "read-only" in str(exc_info.value.detail) -async def test_get_session_no_extend_does_not_extend_expiration( - tmp_path_mocked_home: Path, session_manager: SessionManager -) -> None: - """Tests that get_session_no_extend does not extend session expiration.""" - with pytest.raises(HTTPException, match="401: No active session found"): - await get_session_no_extend(None) - - with pytest.raises(HTTPException, match="401: Invalid or expired session"): - await get_session_no_extend(Cookie(default=uuid4())) - - user_fmu_dir = init_user_fmu_directory() - valid_session = await session_manager.create_session(user_fmu_dir) - session = await get_session_no_extend(valid_session) - assert session.user_fmu_directory.path == user_fmu_dir.path - - with ( - patch( - "fmu_settings_api.deps.session.session_manager.get_session", - side_effect=Exception("foo"), - ), - pytest.raises(HTTPException, match="500: Session error: foo"), - ): - await get_session_no_extend(Cookie(default=object)) - - -async def test_get_project_session_no_extend_does_not_extend_expiration( - tmp_path_mocked_home: Path, session_manager: SessionManager -) -> None: - """Tests that get_project_session_no_extend does not extend session expiration.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - with pytest.raises(HTTPException) as exc_info: - await get_project_session_no_extend(session_id) - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert "No FMU project directory open" in str(exc_info.value.detail) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - await add_fmu_project_to_session(session_id, project_fmu_dir) - - result = await get_project_session_no_extend(session_id) - assert isinstance(result, ProjectSession) - assert result.project_fmu_directory.path == project_fmu_dir.path - - original_expires_at = result.expires_at - result2 = await get_project_session_no_extend(session_id) - assert result2.expires_at == original_expires_at - - project_fmu_dir.path.parent.rename(tmp_path_mocked_home / "deleted") - with pytest.raises(HTTPException) as exc_info: - await get_project_session_no_extend(session_id) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - assert "Project .fmu directory not found" in str(exc_info.value.detail) - - -async def test_get_project_session_extends_expiration( - tmp_path_mocked_home: Path, session_manager: SessionManager -) -> None: - """Tests that get_project_session extends session expiration.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - await add_fmu_project_to_session(session_id, project_fmu_dir) - - result = await get_project_session(session_id) - original_expires_at = result.expires_at - - result2 = await get_project_session(session_id) - assert result2.expires_at > original_expires_at - - async def test_get_session_service_returns_session_service( tmp_path_mocked_home: Path, session_manager: SessionManager ) -> None: """Tests that get_session_service returns a SessionService instance.""" user_fmu_dir = init_user_fmu_directory() - valid_session = await session_manager.create_session(user_fmu_dir) - session = await get_session(valid_session) + valid_session = await create_fmu_session(user_fmu_dir) + session = await get_session(None, valid_session) service = await get_session_service(session) assert isinstance(service, SessionService) @@ -408,85 +327,44 @@ async def test_get_session_service_returns_session_service( assert isinstance(service._session, Session) -async def test_get_session_service_no_extend_does_not_extend_expiration( - tmp_path_mocked_home: Path, session_manager: SessionManager -) -> None: - """Tests that get_session_service_no_extend does not extend session expiration.""" - user_fmu_dir = init_user_fmu_directory() - valid_session = await session_manager.create_session(user_fmu_dir) - session = await get_session_no_extend(valid_session) - - service = await get_session_service_no_extend(session) - assert isinstance(service, SessionService) - assert service._session == session - assert isinstance(service._session, Session) - - original_expires_at = session.expires_at - session2 = await get_session_no_extend(valid_session) - service2 = await get_session_service_no_extend(session2) - assert service2._session.expires_at == original_expires_at - - async def test_get_project_session_service_returns_session_service( tmp_path_mocked_home: Path, session_manager: SessionManager ) -> None: """Tests that get_project_session_service returns a SessionService instance.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() project_fmu_dir = init_fmu_directory(project_path) await add_fmu_project_to_session(session_id, project_fmu_dir) - project_session = await get_project_session(session_id) - service = await get_project_session_service(project_session) - - assert isinstance(service, SessionService) - assert service._session == project_session - assert isinstance(service._session, ProjectSession) - assert service._session.project_fmu_directory.path == project_fmu_dir.path - + session = await get_fmu_session(session_id) -async def test_get_project_session_service_no_extend_does_not_extend_expiration( - tmp_path_mocked_home: Path, session_manager: SessionManager -) -> None: - """Tests that get_project_session_service_no_extend does not extend expiration.""" - user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - await add_fmu_project_to_session(session_id, project_fmu_dir) - - project_session = await get_project_session_no_extend(session_id) - service = await get_project_session_service_no_extend(project_session) + project_session = await get_project_session(session, session_id) + service = await get_project_session_service(project_session) assert isinstance(service, SessionService) assert service._session == project_session assert isinstance(service._session, ProjectSession) assert service._session.project_fmu_directory.path == project_fmu_dir.path - original_expires_at = project_session.expires_at - project_session2 = await get_project_session_no_extend(session_id) - service2 = await get_project_session_service_no_extend(project_session2) - assert service2._session.expires_at == original_expires_at - async def test_get_project_service_returns_project_service( tmp_path_mocked_home: Path, session_manager: SessionManager ) -> None: """Tests that get_project_service returns a ProjectService instance.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() project_fmu_dir = init_fmu_directory(project_path) await add_fmu_project_to_session(session_id, project_fmu_dir) - project_session = await get_project_session(session_id) + session = await get_fmu_session(session_id) + + project_session = await get_project_session(session, session_id) project_service = await get_project_service(project_session) assert isinstance(project_service, ProjectService) @@ -499,7 +377,7 @@ async def test_refresh_lock_dep_refreshes_lock_when_acquired( ) -> None: """Tests that RefreshLockDep refreshes the lock when it is acquired.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -521,7 +399,7 @@ async def test_refresh_lock_dep_does_nothing_when_not_acquired( ) -> None: """Tests that RefreshLockDep does nothing when lock is not acquired.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -543,7 +421,7 @@ async def test_refresh_lock_dep_handles_lock_error( ) -> None: """Tests that RefreshLockDep handles lock errors gracefully.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -557,7 +435,7 @@ async def test_refresh_lock_dep_handles_lock_error( await add_fmu_project_to_session(session_id, project_fmu_dir) await refresh_project_lock_dep(session_id) - session = await session_manager.get_session(session_id, extend_expiration=False) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) assert session.lock_errors.refresh is not None assert "Lock refresh failed" in session.lock_errors.refresh @@ -568,7 +446,7 @@ async def test_refresh_lock_dep_handles_permission_error( ) -> None: """Tests that RefreshLockDep swallows PermissionError exceptions.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -600,7 +478,7 @@ async def test_refresh_lock_dep_no_project_session( ) -> None: """Tests that RefreshLockDep raises 401 when no project session exists.""" user_fmu_dir = init_user_fmu_directory() - session_id = await session_manager.create_session(user_fmu_dir) + session_id = await create_fmu_session(user_fmu_dir) with pytest.raises(HTTPException) as exc_info: await refresh_project_lock_dep(session_id) diff --git a/tests/test_v1/test_project.py b/tests/test_v1/test_project.py index 89a4602..2637f3d 100644 --- a/tests/test_v1/test_project.py +++ b/tests/test_v1/test_project.py @@ -41,6 +41,7 @@ Session, SessionManager, SessionNotFoundError, + get_fmu_session, ) from fmu_settings_api.v1.routes.project import _create_opened_project_response @@ -258,9 +259,7 @@ async def test_get_project_updates_session( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == session_tmp_path / ".fmu" @@ -289,9 +288,7 @@ async def test_get_project_already_in_session( ) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == session_tmp_path / ".fmu" @@ -310,7 +307,7 @@ async def test_get_changelog_success( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -361,7 +358,7 @@ async def test_get_changelog_permission_denied( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch( @@ -596,12 +593,10 @@ async def test_post_project_removes_non_existing_from_user_recent_projects( "recent_project_directories", [session_tmp_path, non_existing_path] ) - from fmu_settings_api.session import session_manager # noqa PLC0415 - # need to force a reload of the user config in the session session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) session.user_fmu_directory.config.load(force=True) response = client_with_session.post(ROUTE, json={"path": str(non_existing_path)}) @@ -630,9 +625,7 @@ async def test_post_fmu_directory_exists( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == session_tmp_path / ".fmu" @@ -662,9 +655,7 @@ async def test_post_fmu_directory_changes_session_instance( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == project_x / ".fmu" @@ -678,7 +669,7 @@ async def test_post_fmu_directory_changes_session_instance( assert fmu_project.project_dir_name == project_y.name assert y_fmu_dir.config.load() == fmu_project.config - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == project_y / ".fmu" @@ -710,13 +701,11 @@ async def test_delete_project_session_returns_to_user_session( client_with_project_session: TestClient, session_tmp_path: Path ) -> None: """Tests that deleting a project session returns to a user session.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - session_id = client_with_project_session.cookies.get( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) @@ -726,7 +715,7 @@ async def test_delete_project_session_returns_to_user_session( deleted_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY, None) assert deleted_session_id is None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, Session) @@ -876,9 +865,7 @@ async def test_post_init_updates_session_instance( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) assert session.project_fmu_directory.path == session_tmp_path / ".fmu" @@ -989,7 +976,7 @@ async def test_patch_masterdata_lockfile_removed( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -1019,7 +1006,7 @@ async def test_patch_masterdata_lockfile_removed_and_acquired_by_other( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -1056,7 +1043,7 @@ async def test_patch_masterdata_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) # Mock the project_fmu_directory.set_config_value to raise ValueError @@ -1451,7 +1438,7 @@ async def test_patch_model_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) # Mock the project_fmu_directory.set_config_value to raise ValueError @@ -1547,7 +1534,7 @@ async def test_patch_access_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) # Mock the project_fmu_directory.set_config_value to raise ValueError @@ -1644,7 +1631,7 @@ async def test_patch_cache_max_revisions_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch.object( @@ -2134,43 +2121,6 @@ async def test_get_lock_status_with_lock_file_not_exists( assert lock_status["lock_file_read_error"] is None -async def test_get_lock_status_does_not_update_expiration( - client_with_project_session: TestClient, - session_id: str, -) -> None: - """Test that lock status endpoint does not update the session expiration.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - before_acquire = await session_manager.get_session( - session_id, extend_expiration=False - ) - # Must be copied. Session is reference - before_acquire_expiration = before_acquire.expires_at - before_acquire_accessed = before_acquire.last_accessed - - # Acquire lock (or something.) - client_with_project_session.post(f"{ROUTE}/lock_acquire") - - before_status = await session_manager.get_session( - session_id, extend_expiration=False - ) - before_expiration = before_status.expires_at - before_accessed = before_status.last_accessed - - assert before_acquire_accessed < before_accessed - assert before_acquire_expiration < before_expiration # Updates expiration - - # Request - client_with_project_session.get(f"{ROUTE}/lock_status") - - after_status = await session_manager.get_session( - session_id, extend_expiration=False - ) - - assert before_accessed < after_status.last_accessed - assert before_expiration == after_status.expires_at - - # POST project/lock_acquire # @@ -2179,9 +2129,7 @@ async def test_post_lock_acquire_success( session_id: str, ) -> None: """Test lock acquire route returns writable project when lock is held.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2207,9 +2155,7 @@ async def test_post_lock_acquire_conflict_returns_read_only( session_id: str, ) -> None: """Test lock acquire route returns read-only when acquisition fails.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2380,9 +2326,7 @@ async def test_post_lock_refresh_success( session_id: str, ) -> None: """Test lock refresh route returns success message after refreshing the lock.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2411,9 +2355,7 @@ async def test_post_lock_refresh_when_not_held( session_id: str, ) -> None: """Test lock refresh route when lock is not held.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2446,9 +2388,7 @@ async def test_post_lock_refresh_records_refresh_error( """Test lock refresh route records error if refresh fails.""" from fmu.settings._resources.lock_manager import LockError # noqa: PLC0415 - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2478,9 +2418,7 @@ async def test_post_lock_refresh_permission_error( session_id: str, ) -> None: """Test lock refresh route swallows permission errors gracefully.""" - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) mock_lock = Mock() @@ -2562,7 +2500,7 @@ async def test_get_rms_projects_returns_list( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch.object( @@ -2593,7 +2531,7 @@ async def test_get_rms_projects_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch.object( @@ -2618,7 +2556,7 @@ async def test_get_rms_projects_file_not_found( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch.object( @@ -2641,7 +2579,7 @@ async def test_get_rms_projects_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch.object( @@ -2837,7 +2775,7 @@ async def test_patch_rms_general_exception( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) rms_path = session_tmp_path / "rms/model/project.rms14.2.2" @@ -3155,7 +3093,7 @@ async def test_get_mappings_stratigraphy_returns_grouped( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3196,7 +3134,7 @@ async def test_get_mappings_stratigraphy_filters_by_systems( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3241,7 +3179,7 @@ async def test_get_mappings_stratigraphy_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3309,7 +3247,7 @@ async def test_get_mappings_stratigraphy_file_not_found( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) with patch( @@ -3337,7 +3275,7 @@ async def test_put_mappings_stratigraphy_success( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3367,7 +3305,7 @@ async def test_put_mappings_stratigraphy_preserves_other_systems( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3419,7 +3357,7 @@ async def test_put_mappings_stratigraphy_body_validation_mismatch( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3451,7 +3389,7 @@ async def test_put_mappings_stratigraphy_body_target_system_mismatch( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3570,7 +3508,7 @@ async def test_get_cache_returns_resource_revisions( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3597,7 +3535,7 @@ async def test_get_cache_resource_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3629,7 +3567,7 @@ async def test_get_cache_revision_returns_resource_content( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3655,7 +3593,7 @@ async def test_get_cache_revision_returns_mappings_content( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3695,7 +3633,7 @@ async def test_get_cache_revision_invalid_resource_json( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3718,7 +3656,7 @@ async def test_get_cache_revision_resource_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3754,7 +3692,7 @@ async def test_get_cache_diff_returns_resource_diff( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3804,7 +3742,7 @@ async def test_get_cache_diff_invalid_resource_json( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3827,7 +3765,7 @@ async def test_get_cache_diff_resource_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3864,7 +3802,7 @@ async def test_post_cache_restore_updates_resource( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3900,7 +3838,7 @@ async def test_post_cache_restore_updates_mappings( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3933,7 +3871,7 @@ async def test_post_cache_restore_resource_missing_file( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -3981,7 +3919,7 @@ async def test_post_cache_restore_invalid_resource_content( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory @@ -4008,7 +3946,7 @@ async def test_post_cache_restore_resource_permission_error( settings.SESSION_COOKIE_KEY, None ) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert isinstance(session, ProjectSession) fmu_dir = session.project_fmu_directory diff --git a/tests/test_v1/test_rms_deps.py b/tests/test_v1/test_rms_deps.py index 8df10ff..898483c 100644 --- a/tests/test_v1/test_rms_deps.py +++ b/tests/test_v1/test_rms_deps.py @@ -1,5 +1,6 @@ """Tests for RMS dependencies.""" +from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock @@ -54,7 +55,10 @@ async def test_get_opened_rms_project_success() -> None: rms_executor_mock = MagicMock(spec=ApiExecutor) rms_project_mock = MagicMock(spec=RmsApiProxy) project_session_mock = MagicMock() - project_session_mock.rms_session = RmsSession(rms_executor_mock, rms_project_mock) + rms_session_expires_at = datetime.now(UTC) + timedelta(seconds=5) + project_session_mock.rms_session = RmsSession( + rms_executor_mock, rms_project_mock, rms_session_expires_at + ) result = await get_opened_rms_project(project_session_mock) diff --git a/tests/test_v1/test_session.py b/tests/test_v1/test_session.py index b552920..c356135 100644 --- a/tests/test_v1/test_session.py +++ b/tests/test_v1/test_session.py @@ -1,8 +1,10 @@ """Tests the /api/v1/session routes.""" import shutil +from datetime import UTC, datetime from pathlib import Path -from unittest.mock import MagicMock, patch +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import status @@ -22,12 +24,17 @@ SessionManager, SessionNotFoundError, add_rms_project_to_session, + get_fmu_session, + update_fmu_session, ) ROUTE = "/api/v1/session" -def test_get_session_no_token() -> None: +# POST session/ # + + +def test_post_session_no_token() -> None: """Tests the fmu routes require a session.""" client = TestClient(app) response = client.post(ROUTE) @@ -35,7 +42,7 @@ def test_get_session_no_token() -> None: assert response.json() == {"detail": "Not authenticated"} -def test_get_session_invalid_token() -> None: +def test_post_session_invalid_token() -> None: """Tests the fmu routes require a session.""" client = TestClient(app) bad_token = "no" * 32 @@ -44,7 +51,7 @@ def test_get_session_invalid_token() -> None: assert response.json() == {"detail": "Not authorized"} -def test_get_session_no_token_does_not_create_user_fmu( +def test_post_session_no_token_does_not_create_user_fmu( tmp_path_mocked_home: Path, ) -> None: """Tests unauthenticated requests do not create a user .fmu.""" @@ -55,7 +62,7 @@ def test_get_session_no_token_does_not_create_user_fmu( assert not (tmp_path_mocked_home / "home/.fmu").exists() -def test_get_session_invalid_token_does_not_create_user_fmu( +def test_post_session_invalid_token_does_not_create_user_fmu( tmp_path_mocked_home: Path, ) -> None: """Tests unauthorized requests do not create a user .fmu.""" @@ -67,7 +74,7 @@ def test_get_session_invalid_token_does_not_create_user_fmu( assert not (tmp_path_mocked_home / "home/.fmu").exists() -def test_get_session_create_user_fmu_no_permissions( +def test_post_session_create_user_fmu_no_permissions( user_fmu_dir_no_permissions: Path, mock_token: str ) -> None: """Tests that user .fmu directory permissions errors return a 403.""" @@ -77,7 +84,7 @@ def test_get_session_create_user_fmu_no_permissions( assert response.json() == {"detail": "Permission denied creating user .fmu"} -def test_get_session_creating_user_fmu_exists_as_a_file( +def test_post_session_creating_user_fmu_exists_as_a_file( tmp_path_mocked_home: Path, mock_token: str, monkeypatch: MonkeyPatch ) -> None: """Tests that a user .fmu as a file raises a 409.""" @@ -91,7 +98,7 @@ def test_get_session_creating_user_fmu_exists_as_a_file( } -def test_get_session_creating_user_unknown_failure( +def test_post_session_creating_user_unknown_failure( tmp_path_mocked_home: Path, mock_token: str, monkeypatch: MonkeyPatch ) -> None: """Tests that an unknown exception returns 500.""" @@ -109,12 +116,12 @@ def test_get_session_creating_user_unknown_failure( assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR -def test_get_session_creates_user_fmu( +async def test_post_session_creates_session_and_user_fmu( tmp_path_mocked_home: Path, mock_token: str, session_manager: SessionManager, ) -> None: - """Tests that user .fmu is created when a session is created.""" + """Tests that a session and user .fmu is created when posting a session.""" client = TestClient(app) user_home = tmp_path_mocked_home / "home" with pytest.raises( @@ -124,47 +131,26 @@ def test_get_session_creates_user_fmu( response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) assert response.status_code == status.HTTP_200_OK, response.json() - # Does not raise - user_fmu_dir = UserFMUDirectory() - payload = response.json() + + # Assert session has been created session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - assert payload["id"] == session_id - assert "created_at" in payload - assert "expires_at" in payload - assert "last_accessed" in payload - assert user_fmu_dir.path == user_home / ".fmu" - - -async def test_get_session_creates_session( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, -) -> None: - """Tests that user .fmu is created when a session is created.""" - client = TestClient(app) - user_home = tmp_path_mocked_home / "home" - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK, response.json() + session = await get_fmu_session(session_id) + assert isinstance(session, Session) + # Assert user fmu has been created and opened in the session user_fmu_dir = UserFMUDirectory() assert user_fmu_dir.path == user_home / ".fmu" - - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - session = await session_manager.get_session(session_id) - assert session is not None - assert isinstance(session, Session) assert session.user_fmu_directory.path == user_fmu_dir.path assert session.user_fmu_directory.config.load() == user_fmu_dir.config.load() -async def test_get_session_finds_existing_user_fmu( +async def test_post_session_finds_existing_user_fmu( tmp_path_mocked_home: Path, mock_token: str, session_manager: SessionManager, ) -> None: - """Tests that an existing user .fmu directory is located with a session.""" + """Tests that an existing user .fmu directory is located when posting a session.""" client = TestClient(app) user_fmu_dir = init_user_fmu_directory() @@ -174,20 +160,20 @@ async def test_get_session_finds_existing_user_fmu( session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, Session) assert session.user_fmu_directory.path == user_fmu_dir.path -async def test_get_session_from_project_path_returns_fmu_project( +async def test_post_session_from_project_path_returns_fmu_project( tmp_path_mocked_home: Path, mock_token: str, monkeypatch: MonkeyPatch, session_manager: SessionManager, ) -> None: - """Tests that user .fmu is created when a session is created.""" + """Tests that project session is created when posting session from project path.""" client = TestClient(app) initial_user_fmu_dir = init_user_fmu_directory() project_fmu_dir = init_fmu_directory(tmp_path_mocked_home) @@ -209,7 +195,7 @@ async def test_get_session_from_project_path_returns_fmu_project( assert "last_accessed" in payload assert user_fmu_dir.path == initial_user_fmu_dir.path - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, ProjectSession) @@ -220,45 +206,160 @@ async def test_get_session_from_project_path_returns_fmu_project( assert session.project_fmu_directory.config.load() == project_fmu_dir.config.load() -async def test_getting_two_sessions_destroys_existing_session( +async def test_post_session_destroy_existing_expired_session( tmp_path_mocked_home: Path, mock_token: str, session_manager: SessionManager, ) -> None: - """Tests that creating a new session destroys the old, if it exists.""" + """Tests creating a new session destroys the old expired session before creation. + + Scenario: A session with the session_id provided already exists, but is expired. + The existing expired session should be destroyed before a new session is created. + """ client = TestClient(app) - user_home = tmp_path_mocked_home / "home" response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) assert response.status_code == status.HTTP_200_OK, response.json() - user_fmu_dir = UserFMUDirectory() - assert user_fmu_dir.path == user_home / ".fmu" - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - session = await session_manager.get_session(session_id) - assert session is not None + session = await get_fmu_session(session_id) + assert session.id == session_id assert isinstance(session, Session) - assert session.user_fmu_directory.path == user_fmu_dir.path - # New session - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK, response.json() + # Expire the session + expired_timestamp = datetime.now(UTC) + session.expires_at = expired_timestamp + await update_fmu_session(session) + # Post new session and assert that destroy was called + with patch( + "fmu_settings_api.deps.session.destroy_fmu_session_if_expired", + new_callable=AsyncMock, + ) as mock_destroy_session: + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.status_code == status.HTTP_200_OK, response.json() + mock_destroy_session.assert_called_once_with(session_id) + + # Expired session should be destroyed and new valid session should be created + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) new_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert new_session_id is not None - new_session = await session_manager.get_session(new_session_id) + assert new_session_id is not session_id + with pytest.raises(SessionNotFoundError, match="No active session found"): + await get_fmu_session(session_id) + + new_session = await get_fmu_session(new_session_id) assert new_session is not None assert isinstance(new_session, Session) - assert new_session.user_fmu_directory.path == user_fmu_dir.path + assert new_session.expires_at > expired_timestamp - # Ensure not same and destroyed - assert session_id != new_session_id - with pytest.raises(SessionNotFoundError, match="No active session found"): - await session_manager.get_session(session_id) +async def test_post_session_adds_project_to_existing_valid_session( + tmp_path_mocked_home: Path, + client_with_session: TestClient, + session_manager: SessionManager, + mock_token: str, + monkeypatch: MonkeyPatch, +) -> None: + """Tests creating a new session adds a project to the existing valid session. + + Scenario: A valid user session with the session_id provided already exists. + The existing session should be kept and a project should be added, if existing. + """ + client = TestClient(app) + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.status_code == status.HTTP_200_OK, response.json() + + session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert session_id is not None + session = await get_fmu_session(session_id) + assert session.id == session_id + assert isinstance(session, Session) + + # Add a .fmu directory to the current path + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + init_fmu_directory(project_path) + monkeypatch.chdir(project_path) + + # Post new session and assert that a project was added to the existing session + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.cookies.get(settings.SESSION_COOKIE_KEY) == session_id + updated_session = await get_fmu_session(session_id) + assert isinstance(updated_session, ProjectSession) -async def test_session_creation_handles_lock_conflicts( + +async def test_post_session_keeps_existing_valid_project_session( + tmp_path_mocked_home: Path, + client_with_session: TestClient, + session_manager: SessionManager, + mock_token: str, + monkeypatch: MonkeyPatch, +) -> None: + """Tests creating new session keeps the existing project session and skips creation. + + Scenario: A valid project session with the session_id provided already exists. + The existing session should be kept and no new session should be created. + """ + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + init_fmu_directory(project_path) + monkeypatch.chdir(project_path) + + # Create new session with project and RMS session + client = TestClient(app) + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.status_code == status.HTTP_200_OK, response.json() + session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert session_id is not None + session = await get_fmu_session(session_id) + assert session.id == session_id + assert isinstance(session, ProjectSession) + + # Assert create session skips creating new session and add project + with ( + patch( + "fmu_settings_api.session.create_fmu_session", new_callable=AsyncMock + ) as mock_create_session, + patch( + "fmu_settings_api.session.add_fmu_project_to_session" + ) as mock_add_project_to_session, + ): + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.status_code == status.HTTP_200_OK, response.json() + mock_create_session.assert_not_called() + mock_add_project_to_session.assert_not_called() + + assert response.cookies.get(settings.SESSION_COOKIE_KEY) == session_id + assert await get_fmu_session(session_id) == session + + # Assert session is kept the same when valid project session exists + client.patch( + f"{ROUTE}/access_token", + json={"id": "smda_api", "key": "secret_token"}, + ) + + rms_executor = MagicMock(shutdown=MagicMock()) + rms_project = MagicMock(close=MagicMock()) + await add_rms_project_to_session(session_id, rms_executor, rms_project) + + updated_session = cast("ProjectSession", await get_fmu_session(session_id)) + assert updated_session.rms_session is not None + assert updated_session.rms_session == RmsSession( + rms_executor, rms_project, updated_session.rms_session.expires_at + ) + + different_path = tmp_path_mocked_home / "different_project" + different_path.mkdir() + monkeypatch.chdir(different_path) + + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.cookies.get(settings.SESSION_COOKIE_KEY) == updated_session.id + assert updated_session == await get_fmu_session(session_id) + + +async def test_post_session_handles_lock_conflicts( tmp_path_mocked_home: Path, mock_token: str, session_manager: SessionManager, @@ -287,13 +388,133 @@ async def test_session_creation_handles_lock_conflicts( session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert isinstance(session, Session) assert not hasattr(session, "project_fmu_directory") +def test_post_session_handles_general_exception( + tmp_path_mocked_home: Path, mock_token: str +) -> None: + """Tests that session creation handles general exceptions properly.""" + client = TestClient(app) + + with patch( + "fmu_settings_api.v1.routes.session.create_fmu_session", + side_effect=RuntimeError("Session creation failed"), + ): + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.json()["detail"] == "An unexpected error occurred." + + +# PATCH session/ # + + +async def test_refresh_session_refreshes_existing_valid_session( + tmp_path_mocked_home: Path, + mock_token: str, + session_manager: SessionManager, + monkeypatch: MonkeyPatch, +) -> None: + """Tests that the expiration time for a valid session is extended.""" + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + init_fmu_directory(project_path) + monkeypatch.chdir(project_path) + + client = TestClient(app) + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert session_id is not None + + rms_executor = MagicMock(shutdown=MagicMock()) + rms_project = MagicMock(close=MagicMock()) + await add_rms_project_to_session(session_id, rms_executor, rms_project) + session = cast("ProjectSession", await get_fmu_session(session_id)) + assert session.rms_session is not None + + session_expires_at = session.expires_at + rms_session_expires_at = session.rms_session.expires_at + + response = client.patch(ROUTE) + session = cast("ProjectSession", await get_fmu_session(session_id)) + assert session.rms_session is not None + + assert session.expires_at > session_expires_at + assert session.rms_session.expires_at > rms_session_expires_at + + +async def test_refresh_session_when_expired_session( + tmp_path_mocked_home: Path, + mock_token: str, + session_manager: SessionManager, + monkeypatch: MonkeyPatch, +) -> None: + """Tests that refreshing an expired session removes or destroys session. + + Scenario 1: When the RMS session has expired, the RMS session should + be removed from the session. + Scenario 2: When the session has expired, the session should be destroyed. + """ + project_path = tmp_path_mocked_home / "test_project" + project_path.mkdir() + init_fmu_directory(project_path) + monkeypatch.chdir(project_path) + + client = TestClient(app) + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert session_id is not None + + rms_executor = MagicMock(shutdown=MagicMock()) + rms_project = MagicMock(close=MagicMock()) + await add_rms_project_to_session(session_id, rms_executor, rms_project) + session = cast("ProjectSession", await get_fmu_session(session_id)) + assert session.rms_session is not None + + # Expire the RMS session only + expired_timestamp = datetime.now(UTC) + session.rms_session.expires_at = expired_timestamp + await update_fmu_session(session) + + # Assert RMS session removed and user session refreshed + session_expires_at = session.expires_at + response = client.patch(ROUTE) + session = cast("ProjectSession", await get_fmu_session(session_id)) + assert session.rms_session is None + assert session.expires_at > session_expires_at + + # Expire the user session only + await add_rms_project_to_session(session_id, rms_executor, rms_project) + updated_session = await get_fmu_session(session_id) + assert isinstance(updated_session, ProjectSession) + assert updated_session.rms_session is not None + updated_session.expires_at = expired_timestamp + await update_fmu_session(updated_session) + + # Assert session has been destroyed + response = client.patch(ROUTE) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["detail"] == "No active session found" + with pytest.raises(SessionNotFoundError): + await get_fmu_session(session_id) + + +async def test_refresh_session_requires_cookie() -> None: + """Tests that refreshing session with a missing cookie returns 401.""" + client = TestClient(app) + response = client.patch(ROUTE) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["detail"] == "No active session found" + + +# GET session/ # + + async def test_get_session_returns_sanitised_payload( client_with_session: TestClient, session_manager: SessionManager, @@ -305,31 +526,31 @@ async def test_get_session_returns_sanitised_payload( payload = response.json() session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert payload["id"] == session.id assert "user_fmu_directory" not in payload assert "access_tokens" not in payload -async def test_get_session_does_not_extend_expiration( +async def test_get_session_destroys_expired_session( client_with_session: TestClient, session_manager: SessionManager, ) -> None: - """Tests that GET /session should not extend session expiration.""" + """Tests that get session destroys session when it has expired.""" session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY) assert session_id is not None - session = await session_manager.get_session(session_id) - original_expires_at = session.expires_at + # Expire session + session = await get_fmu_session(session_id) + session.expires_at = datetime.now(UTC) + await update_fmu_session(session) response = client_with_session.get(ROUTE) - assert response.status_code == status.HTTP_200_OK - - refreshed_session = await session_manager.get_session( - session_id, extend_expiration=False - ) - assert refreshed_session.expires_at == original_expires_at + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["detail"] == "No active session found" + with pytest.raises(SessionNotFoundError): + session = await get_fmu_session(session_id) def test_get_session_requires_cookie() -> None: @@ -351,6 +572,9 @@ def test_get_session_unknown_failure(client_with_session: TestClient) -> None: assert response.json()["detail"] == "An unexpected error occurred." +# PATCH session/access_token # + + def test_patch_invalid_access_token_key_to_session( client_with_session: TestClient, ) -> None: @@ -373,9 +597,7 @@ async def test_patch_access_token_to_user_fmu_session( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert session.access_tokens.smda_api is None @@ -389,7 +611,7 @@ async def test_patch_access_token_to_user_fmu_session( assert response.status_code == status.HTTP_200_OK assert response.json()["message"] == "Set session access token for smda_api" - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session.access_tokens.smda_api == SecretStr("secret") @@ -404,9 +626,7 @@ async def test_patch_access_token_unknown_failure( session_id = client_with_session.cookies.get(settings.SESSION_COOKIE_KEY, None) assert session_id is not None - from fmu_settings_api.session import session_manager # noqa: PLC0415 - - session = await session_manager.get_session(session_id) + session = await get_fmu_session(session_id) assert session is not None assert session.access_tokens.smda_api is None @@ -421,150 +641,6 @@ async def test_patch_access_token_unknown_failure( assert response.json()["detail"] == "An unexpected error occurred." -def test_post_session_handles_general_exception( - tmp_path_mocked_home: Path, mock_token: str -) -> None: - """Tests that session creation handles general exceptions properly.""" - client = TestClient(app) - - with patch( - "fmu_settings_api.v1.routes.session.create_fmu_session", - side_effect=RuntimeError("Session creation failed"), - ): - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["detail"] == "An unexpected error occurred." - - -async def test_new_session_preserves_state_from_old_session( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, - monkeypatch: MonkeyPatch, -) -> None: - """Tests that creating a new session. - - Preserves access tokens and project from the old session. - """ - client = TestClient(app) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK - - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - - client.patch( - f"{ROUTE}/access_token", - json={"id": "smda_api", "key": "secret_token"}, - ) - - session = await session_manager.get_session(session_id) - assert isinstance(session, ProjectSession) - assert session.access_tokens.smda_api == SecretStr("secret_token") - assert session.project_fmu_directory.path == project_fmu_dir.path - - different_path = tmp_path_mocked_home / "different_project" - different_path.mkdir() - monkeypatch.chdir(different_path) - - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK - - new_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert new_session_id is not None - assert new_session_id != session_id - - new_session = await session_manager.get_session(new_session_id) - assert isinstance(new_session, ProjectSession) - assert new_session.access_tokens.smda_api == SecretStr("secret_token") - assert new_session.project_fmu_directory.path == project_fmu_dir.path - - with pytest.raises(SessionNotFoundError): - await session_manager.get_session(session_id) - - -async def test_new_session_preserves_rms_project_from_old_session( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, - monkeypatch: MonkeyPatch, -) -> None: - """Tests that creating a new session migrates an open RMS project.""" - client = TestClient(app) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK - - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - - rms_executor = MagicMock(shutdown=MagicMock()) - rms_project = MagicMock(close=MagicMock()) - await add_rms_project_to_session(session_id, rms_executor, rms_project) - - session = await session_manager.get_session(session_id) - assert isinstance(session, ProjectSession) - assert session.rms_session == RmsSession(rms_executor, rms_project) - - different_path = tmp_path_mocked_home / "different_project" - different_path.mkdir() - monkeypatch.chdir(different_path) - - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK - - new_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert new_session_id is not None - assert new_session_id != session_id - - new_session = await session_manager.get_session(new_session_id) - assert isinstance(new_session, ProjectSession) - assert new_session.rms_session == RmsSession(rms_executor, rms_project) - - with pytest.raises(SessionNotFoundError): - await session_manager.get_session(session_id) - - rms_executor.shutdown.assert_not_called() - rms_project.close.assert_not_called() - - -async def test_new_session_without_old_session_finds_nearest_project( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, - monkeypatch: MonkeyPatch, -) -> None: - """Tests that when there's no old session, new session finds nearest project.""" - client = TestClient(app) - - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK - - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - - session = await session_manager.get_session(session_id) - assert isinstance(session, ProjectSession) - assert session.project_fmu_directory.path == project_fmu_dir.path - - async def test_post_restore_session_restores_user_fmu_directory( client_with_session: TestClient, session_manager: SessionManager, From 5c49a9969eb4d126029b86f12f78abaf12a593da Mon Sep 17 00:00:00 2001 From: Muhammad Gibran Alfarizi Date: Mon, 30 Mar 2026 10:56:13 +0200 Subject: [PATCH 2/4] MAINT: Simplify the logics --- src/fmu_settings_api/config.py | 11 +- src/fmu_settings_api/deps/rms.py | 7 +- src/fmu_settings_api/deps/session.py | 15 +- src/fmu_settings_api/models/session.py | 2 +- src/fmu_settings_api/session.py | 23 ++-- src/fmu_settings_api/v1/routes/session.py | 79 +++-------- tests/test_main_config.py | 4 +- tests/test_session_manager.py | 67 ++------- tests/test_v1/test_deps.py | 4 +- tests/test_v1/test_rms_deps.py | 35 +++-- tests/test_v1/test_session.py | 160 ++++------------------ 11 files changed, 117 insertions(+), 290 deletions(-) diff --git a/src/fmu_settings_api/config.py b/src/fmu_settings_api/config.py index df93565..ca6006d 100644 --- a/src/fmu_settings_api/config.py +++ b/src/fmu_settings_api/config.py @@ -2,6 +2,7 @@ import hashlib import secrets +from datetime import timedelta from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Self from pydantic import ( @@ -63,8 +64,14 @@ class APISettings(BaseModel): API_V1_PREFIX: str = Field(default="/api/v1", frozen=True) SESSION_COOKIE_KEY: str = Field(default="fmu_settings_session", frozen=True) - SESSION_EXPIRE_SECONDS: int = Field(default=31556926, frozen=True) # 1 year - RMS_SESSION_EXPIRE_SECONDS: int = Field(default=3600, frozen=True) # 60 minutes + SESSION_EXPIRE_SECONDS: int = Field( + default=int(timedelta(days=365).total_seconds()), + frozen=True, + ) # 365 days + RMS_SESSION_EXPIRE_SECONDS: int = Field( + default=int(timedelta(minutes=120).total_seconds()), + frozen=True, + ) # 2 hours APP_NAME: str = Field(default="fmu-settings-api", frozen=True) APP_VERSION: str = Field(default=__version__, frozen=True) TOKEN: str = Field( diff --git a/src/fmu_settings_api/deps/rms.py b/src/fmu_settings_api/deps/rms.py index 89a6b10..b0694ed 100644 --- a/src/fmu_settings_api/deps/rms.py +++ b/src/fmu_settings_api/deps/rms.py @@ -7,6 +7,7 @@ from runrms.api import RmsApiProxy from fmu_settings_api.services.rms import RmsService +from fmu_settings_api.session import refresh_rms_session from .project import ProjectServiceDep from .session import ProjectSessionDep @@ -37,7 +38,7 @@ async def get_rms_project_path(project_service: ProjectServiceDep) -> Path: async def get_opened_rms_project( project_session: ProjectSessionDep, ) -> RmsApiProxy: - """Returns the opened RMS project from the session.""" + """Returns the opened RMS project from the session and refreshes its expiry.""" if project_session.rms_session is None: raise HTTPException( status_code=400, @@ -45,7 +46,9 @@ async def get_opened_rms_project( "No RMS project is currently open. Please open an RMS project first." ), ) - return project_session.rms_session.project + + refreshed_rms_session = await refresh_rms_session(project_session) + return refreshed_rms_session.project RmsProjectDep = Annotated[RmsApiProxy, Depends(get_opened_rms_project)] diff --git a/src/fmu_settings_api/deps/session.py b/src/fmu_settings_api/deps/session.py index ce692a3..8879f03 100644 --- a/src/fmu_settings_api/deps/session.py +++ b/src/fmu_settings_api/deps/session.py @@ -59,10 +59,7 @@ async def get_session( SessionDep = Annotated[Session, Depends(get_session)] -async def get_project_session( - session: SessionDep, - fmu_settings_session: str | None = Cookie(None), -) -> ProjectSession: +async def get_project_session(session: SessionDep) -> ProjectSession: """Gets a session with an FMU Project opened from the session manager.""" if not isinstance(session, ProjectSession): raise HTTPException( @@ -100,19 +97,13 @@ async def ensure_smda_session(session: Session) -> None: ) -async def get_smda_session( - session: SessionDep, - fmu_settings_session: str | None = Cookie(None), -) -> Session: +async def get_smda_session(session: SessionDep) -> Session: """Gets a session capable of querying SMDA from the session manager.""" await ensure_smda_session(session) return session -async def get_project_smda_session( - session: ProjectSessionDep, - fmu_settings_session: str | None = Cookie(None), -) -> ProjectSession: +async def get_project_smda_session(session: ProjectSessionDep) -> ProjectSession: """Returns a project .fmu session that is SMDA-querying capable.""" await ensure_smda_session(session) return session diff --git a/src/fmu_settings_api/models/session.py b/src/fmu_settings_api/models/session.py index 92f0f9b..28dca0d 100644 --- a/src/fmu_settings_api/models/session.py +++ b/src/fmu_settings_api/models/session.py @@ -18,7 +18,7 @@ class SessionResponse(BaseResponseModel): """Timestamp when the session will expire.""" rms_expires_at: datetime | None - """Timestamp when the rms session will expire.""" + """Timestamp when the RMS session will expire.""" last_accessed: datetime """Timestamp when the session was last accessed.""" diff --git a/src/fmu_settings_api/session.py b/src/fmu_settings_api/session.py index 34b0cd6..0c12a5c 100644 --- a/src/fmu_settings_api/session.py +++ b/src/fmu_settings_api/session.py @@ -231,7 +231,7 @@ async def update_fmu_session(session: Session | ProjectSession) -> None: async def destroy_fmu_session_if_expired(session_id: str) -> None: """Destroys the expired sessions in the session manager for the given session_id. - Cheks the user and rms session for the given session_id and destroy the ones + Checks the user and rms session for the given session_id and destroy the ones that has expired. """ try: @@ -247,18 +247,17 @@ async def destroy_fmu_session_if_expired(session_id: str) -> None: await session_manager.destroy_session(session_id) -async def refresh_fmu_session(session_id: str) -> Session | ProjectSession: - """Refresh a session in the session manager by extending the expiration time.""" - session: Session | ProjectSession = await get_fmu_session(session_id) - now = datetime.now(UTC) - session.expires_at = now + timedelta(seconds=settings.SESSION_EXPIRE_SECONDS) - if isinstance(session, ProjectSession) and session.rms_session is not None: - session.rms_session.expires_at = now + timedelta( - seconds=settings.RMS_SESSION_EXPIRE_SECONDS - ) +async def refresh_rms_session(project_session: ProjectSession) -> RmsSession: + """Refresh the expiration time for an opened RMS session.""" + rms_session = project_session.rms_session + if rms_session is None: + raise SessionNotFoundError("No RMS project is currently open") - await update_fmu_session(session) - return session + rms_session.expires_at = datetime.now(UTC) + timedelta( + seconds=settings.RMS_SESSION_EXPIRE_SECONDS + ) + await update_fmu_session(project_session) + return rms_session async def add_fmu_project_to_session( diff --git a/src/fmu_settings_api/v1/routes/session.py b/src/fmu_settings_api/v1/routes/session.py index b04ecd9..48a6d58 100644 --- a/src/fmu_settings_api/v1/routes/session.py +++ b/src/fmu_settings_api/v1/routes/session.py @@ -15,16 +15,13 @@ SessionServiceDep, UserFMUDirDep, ) -from fmu_settings_api.deps.session import DestroySessionIfExpiredDep, SessionDep +from fmu_settings_api.deps.session import DestroySessionIfExpiredDep from fmu_settings_api.models import AccessToken, Message, SessionResponse from fmu_settings_api.session import ( - ProjectSession, SessionNotFoundError, add_fmu_project_to_session, create_fmu_session, get_fmu_session, - get_rms_session_expiration, - refresh_fmu_session, ) from fmu_settings_api.v1.responses import ( CreateSessionResponses, @@ -56,20 +53,20 @@ @router.post( "/", response_model=SessionResponse, - summary="Creates a new user session or adds a project to the existing one.", + summary="Creates a new user session.", description=dedent( """ When creating a session the application will ensure that the user .fmu directory exists by creating it if it does not. - If a session already exists when POSTing to this route, a project will be added - to the existing session if a project can be found. In case a session with a - project already exists, the existing session will kept as-is and - no new session is created. + If a valid session already exists when POSTing to this route, the + request will return a 409 conflict response and no new session will + be created. - When adding a project to the session, the application will attempt to find the + After creating the session, the application will attempt to find the nearest project .fmu directory above the current working directory and - add it to the session if found. If not found, no project will be associated. + add it to the session if found. If not found, no project will be + associated. The session cookie set by this route is required for all other routes. Sessions are not persisted when the API is shut down. @@ -84,32 +81,18 @@ async def post_session( expired_session_dep: DestroySessionIfExpiredDep, fmu_settings_session: Annotated[str | None, Cookie()] = None, ) -> SessionResponse: - """Creates a new user session or adds a project to the existing one.""" + """Creates a new user session.""" if fmu_settings_session: try: - existing_session = await get_fmu_session(fmu_settings_session) - if isinstance(existing_session, ProjectSession): - response.set_cookie( - key=settings.SESSION_COOKIE_KEY, - value=existing_session.id, - httponly=True, - secure=False, - samesite="lax", - ) - return SessionResponse( - id=existing_session.id, - created_at=existing_session.created_at, - expires_at=existing_session.expires_at, - rms_expires_at=await get_rms_session_expiration( - existing_session.id - ), - last_accessed=existing_session.last_accessed, - ) - session_id = existing_session.id + await get_fmu_session(fmu_settings_session) + raise HTTPException( + status_code=409, + detail="A session already exists", + ) except SessionNotFoundError: - session_id = await create_fmu_session(user_fmu_dir) - else: - session_id = await create_fmu_session(user_fmu_dir) + pass + + session_id = await create_fmu_session(user_fmu_dir) response.set_cookie( key=settings.SESSION_COOKIE_KEY, @@ -135,34 +118,6 @@ async def post_session( ) -@router.patch( - "/", - response_model=SessionResponse, - summary="Refresh a session for the user.", - description=dedent( - """ - Refresh an existing session for the user by extending the - session expiration time. If an RMS session is present, this will - also be refreshed. - """ - ), - responses=GetSessionResponses, -) -async def refresh_session( - response: Response, - session: SessionDep, -) -> SessionResponse: - """Refresh an existing user session.""" - session = await refresh_fmu_session(session.id) - return SessionResponse( - id=session.id, - created_at=session.created_at, - expires_at=session.expires_at, - rms_expires_at=await get_rms_session_expiration(session.id), - last_accessed=session.last_accessed, - ) - - @router.patch( "/access_token", response_model=Message, diff --git a/tests/test_main_config.py b/tests/test_main_config.py index 2bdbec0..703e111 100644 --- a/tests/test_main_config.py +++ b/tests/test_main_config.py @@ -134,14 +134,14 @@ def test_app_version_constant() -> None: def test_session_expire_seconds() -> None: """Test SESSION_EXPIRE_SECONDS is set correctly.""" settings = APISettings() - expected_seconds = 31556926 # 1 year + expected_seconds = 31536000 # 365 days assert expected_seconds == settings.SESSION_EXPIRE_SECONDS def test_rms_session_expire_seconds() -> None: """Test SESSION_EXPIRE_SECONDS is set correctly.""" settings = APISettings() - expected_seconds = 3600 # 60 minutes + expected_seconds = 7200 # 120 minutes assert expected_seconds == settings.RMS_SESSION_EXPIRE_SECONDS diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index ab9c4c1..d00d577 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -26,8 +26,8 @@ destroy_fmu_session_if_expired, get_fmu_session, get_rms_session_expiration, - refresh_fmu_session, refresh_project_lock, + refresh_rms_session, release_project_lock, remove_fmu_project_from_session, remove_rms_project_from_session, @@ -407,64 +407,23 @@ async def test_destroy_fmu_session_if_expired_when_session_is_valid() -> None: mocked_session_manager.destroy_session.assert_not_called() -async def test_refresh_fmu_session_with_rms_project() -> None: - """Tests that both the user session and RMS session are refreshed.""" - mocked_session = AsyncMock(spec=ProjectSession) +async def test_refresh_rms_session_with_open_rms_project() -> None: + """Tests that the RMS session expiration is refreshed.""" + mocked_session = MagicMock(spec=ProjectSession) mocked_rms_session = MagicMock() - mocked_session_id = "mocked_id" - mocked_session.id = mocked_session_id mocked_session.rms_session = mocked_rms_session mocked_now = datetime.now(UTC) - mocked_session.expires_at = mocked_now - mocked_session.rms_session.expires_at = mocked_now - mocked_session.last_accessed = mocked_now - - with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: - mocked_get_session = AsyncMock(return_value=mocked_session) - mocked_update_session = AsyncMock(return_value=None) - mocked_session_manager.get_session.side_effect = mocked_get_session - mocked_session_manager.update_session.side_effect = mocked_update_session - - await refresh_fmu_session(mocked_session.id) - - mocked_session_manager.get_session.assert_called_with(mocked_session_id) - mocked_session_manager.update_session.assert_called_once_with( - mocked_session_id, mocked_session - ) - updated_session_id = mocked_session_manager.update_session.call_args[0][0] - updated_session = mocked_session_manager.update_session.call_args[0][1] - assert updated_session_id == mocked_session_id - assert updated_session == mocked_session - assert updated_session.id == mocked_session_id - assert updated_session.expires_at > mocked_now - assert updated_session.rms_session.expires_at > mocked_now + mocked_rms_session.expires_at = mocked_now + with patch( + "fmu_settings_api.session.update_fmu_session", + new_callable=AsyncMock, + ) as mock_update_session: + refreshed_rms_session = await refresh_rms_session(mocked_session) -async def test_refresh_fmu_session_without_rms_project() -> None: - """Tests that only the user session is refreshed.""" - mocked_session = AsyncMock(spec=ProjectSession) - mocked_session_id = "mocked_id" - mocked_session.id = mocked_session_id - mocked_now = datetime.now(UTC) - mocked_session.expires_at = mocked_now - mocked_session.rms_session = None - mocked_session.last_accessed = mocked_now - - with patch("fmu_settings_api.session.session_manager") as mocked_session_manager: - mocked_get_session = AsyncMock(return_value=mocked_session) - mocked_update_session = AsyncMock(return_value=None) - mocked_session_manager.get_session.side_effect = mocked_get_session - mocked_session_manager.update_session.side_effect = mocked_update_session - - await refresh_fmu_session(mocked_session.id) - - mocked_session_manager.get_session.assert_called_with(mocked_session_id) - mocked_session_manager.update_session.assert_called_once() - updated_session = mocked_session_manager.update_session.call_args[0][1] - assert updated_session == mocked_session - assert updated_session.id == mocked_session_id - assert updated_session.expires_at > mocked_now - assert updated_session.rms_session is None + mock_update_session.assert_awaited_once_with(mocked_session) + assert mocked_session.rms_session.expires_at > mocked_now + assert refreshed_rms_session == mocked_rms_session async def test_add_fmu_project_to_session_acquires_lock( diff --git a/tests/test_v1/test_deps.py b/tests/test_v1/test_deps.py index aa1b7ab..7a0242c 100644 --- a/tests/test_v1/test_deps.py +++ b/tests/test_v1/test_deps.py @@ -341,7 +341,7 @@ async def test_get_project_session_service_returns_session_service( session = await get_fmu_session(session_id) - project_session = await get_project_session(session, session_id) + project_session = await get_project_session(session) service = await get_project_session_service(project_session) assert isinstance(service, SessionService) @@ -364,7 +364,7 @@ async def test_get_project_service_returns_project_service( session = await get_fmu_session(session_id) - project_session = await get_project_session(session, session_id) + project_session = await get_project_session(session) project_service = await get_project_service(project_session) assert isinstance(project_service, ProjectService) diff --git a/tests/test_v1/test_rms_deps.py b/tests/test_v1/test_rms_deps.py index 898483c..78c132a 100644 --- a/tests/test_v1/test_rms_deps.py +++ b/tests/test_v1/test_rms_deps.py @@ -2,10 +2,10 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastapi import HTTPException +from fastapi import HTTPException, status from runrms.api import RmsApiProxy from runrms.executor import ApiExecutor @@ -43,7 +43,7 @@ async def test_get_rms_project_path_not_configured() -> None: with pytest.raises(HTTPException) as exc_info: await get_rms_project_path(project_service_mock) - assert exc_info.value.status_code == 400 # noqa: PLR2004 + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert ( exc_info.value.detail == "RMS project path is not set in the project config file." @@ -51,7 +51,7 @@ async def test_get_rms_project_path_not_configured() -> None: async def test_get_opened_rms_project_success() -> None: - """Test getting opened RMS project when one is open.""" + """Test getting opened RMS project refreshes the RMS session expiry.""" rms_executor_mock = MagicMock(spec=ApiExecutor) rms_project_mock = MagicMock(spec=RmsApiProxy) project_session_mock = MagicMock() @@ -60,20 +60,39 @@ async def test_get_opened_rms_project_success() -> None: rms_executor_mock, rms_project_mock, rms_session_expires_at ) - result = await get_opened_rms_project(project_session_mock) + refreshed_rms_session = RmsSession( + rms_executor_mock, + rms_project_mock, + datetime.now(UTC) + timedelta(seconds=30), + ) + + with patch( + "fmu_settings_api.deps.rms.refresh_rms_session", + new_callable=AsyncMock, + return_value=refreshed_rms_session, + ) as mock_refresh_rms_session: + result = await get_opened_rms_project(project_session_mock) assert result is rms_project_mock + mock_refresh_rms_session.assert_awaited_once_with(project_session_mock) async def test_get_opened_rms_project_none_open() -> None: - """Test that HTTPException is raised when no RMS project is open.""" + """Test that missing RMS project returns 400 without attempting refresh.""" project_session_mock = MagicMock() project_session_mock.rms_session = None - with pytest.raises(HTTPException) as exc_info: + with ( + patch( + "fmu_settings_api.deps.rms.refresh_rms_session", + new_callable=AsyncMock, + ) as mock_refresh_rms_session, + pytest.raises(HTTPException) as exc_info, + ): await get_opened_rms_project(project_session_mock) - assert exc_info.value.status_code == 400 # noqa: PLR2004 + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail == ( "No RMS project is currently open. Please open an RMS project first." ) + mock_refresh_rms_session.assert_not_awaited() diff --git a/tests/test_v1/test_session.py b/tests/test_v1/test_session.py index c356135..02eef65 100644 --- a/tests/test_v1/test_session.py +++ b/tests/test_v1/test_session.py @@ -237,7 +237,8 @@ async def test_post_session_destroy_existing_expired_session( new_callable=AsyncMock, ) as mock_destroy_session: response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK, response.json() + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json() == {"detail": "A session already exists"} mock_destroy_session.assert_called_once_with(session_id) # Expired session should be destroyed and new valid session should be created @@ -255,17 +256,17 @@ async def test_post_session_destroy_existing_expired_session( assert new_session.expires_at > expired_timestamp -async def test_post_session_adds_project_to_existing_valid_session( +async def test_post_session_returns_conflict_for_existing_valid_session( tmp_path_mocked_home: Path, client_with_session: TestClient, session_manager: SessionManager, mock_token: str, - monkeypatch: MonkeyPatch, ) -> None: - """Tests creating a new session adds a project to the existing valid session. + """Tests creating a new session returns 409 when one already exists. Scenario: A valid user session with the session_id provided already exists. - The existing session should be kept and a project should be added, if existing. + The existing session should be kept unchanged and the API should return + a conflict instead of creating a new session. """ client = TestClient(app) response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) @@ -277,30 +278,28 @@ async def test_post_session_adds_project_to_existing_valid_session( assert session.id == session_id assert isinstance(session, Session) - # Add a .fmu directory to the current path - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - # Post new session and assert that a project was added to the existing session + # Posting again should keep the current session and return a conflict response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.cookies.get(settings.SESSION_COOKIE_KEY) == session_id + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json() == {"detail": "A session already exists"} + updated_session = await get_fmu_session(session_id) - assert isinstance(updated_session, ProjectSession) + assert updated_session == session -async def test_post_session_keeps_existing_valid_project_session( +async def test_post_session_returns_conflict_for_existing_project_session( tmp_path_mocked_home: Path, client_with_session: TestClient, session_manager: SessionManager, mock_token: str, monkeypatch: MonkeyPatch, ) -> None: - """Tests creating new session keeps the existing project session and skips creation. + """Tests creating a new session returns 409 for an existing project session. - Scenario: A valid project session with the session_id provided already exists. - The existing session should be kept and no new session should be created. + Scenario: A valid project session with the session_id provided already + exists. The existing project session should be kept unchanged, no new + session should be created, and the attached project should not be swapped + even if the current working directory changes. """ project_path = tmp_path_mocked_home / "test_project" project_path.mkdir() @@ -317,21 +316,15 @@ async def test_post_session_keeps_existing_valid_project_session( assert session.id == session_id assert isinstance(session, ProjectSession) - # Assert create session skips creating new session and add project - with ( - patch( - "fmu_settings_api.session.create_fmu_session", new_callable=AsyncMock - ) as mock_create_session, - patch( - "fmu_settings_api.session.add_fmu_project_to_session" - ) as mock_add_project_to_session, - ): + # Posting again should not create a replacement session + with patch( + "fmu_settings_api.v1.routes.session.create_fmu_session", + new_callable=AsyncMock, + ) as mock_create_session: response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_200_OK, response.json() + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json() == {"detail": "A session already exists"} mock_create_session.assert_not_called() - mock_add_project_to_session.assert_not_called() - - assert response.cookies.get(settings.SESSION_COOKIE_KEY) == session_id assert await get_fmu_session(session_id) == session # Assert session is kept the same when valid project session exists @@ -354,8 +347,10 @@ async def test_post_session_keeps_existing_valid_project_session( different_path.mkdir() monkeypatch.chdir(different_path) + # Changing cwd should still not replace the existing project session response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.cookies.get(settings.SESSION_COOKIE_KEY) == updated_session.id + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json() == {"detail": "A session already exists"} assert updated_session == await get_fmu_session(session_id) @@ -411,107 +406,6 @@ def test_post_session_handles_general_exception( assert response.json()["detail"] == "An unexpected error occurred." -# PATCH session/ # - - -async def test_refresh_session_refreshes_existing_valid_session( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, - monkeypatch: MonkeyPatch, -) -> None: - """Tests that the expiration time for a valid session is extended.""" - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - client = TestClient(app) - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - - rms_executor = MagicMock(shutdown=MagicMock()) - rms_project = MagicMock(close=MagicMock()) - await add_rms_project_to_session(session_id, rms_executor, rms_project) - session = cast("ProjectSession", await get_fmu_session(session_id)) - assert session.rms_session is not None - - session_expires_at = session.expires_at - rms_session_expires_at = session.rms_session.expires_at - - response = client.patch(ROUTE) - session = cast("ProjectSession", await get_fmu_session(session_id)) - assert session.rms_session is not None - - assert session.expires_at > session_expires_at - assert session.rms_session.expires_at > rms_session_expires_at - - -async def test_refresh_session_when_expired_session( - tmp_path_mocked_home: Path, - mock_token: str, - session_manager: SessionManager, - monkeypatch: MonkeyPatch, -) -> None: - """Tests that refreshing an expired session removes or destroys session. - - Scenario 1: When the RMS session has expired, the RMS session should - be removed from the session. - Scenario 2: When the session has expired, the session should be destroyed. - """ - project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - init_fmu_directory(project_path) - monkeypatch.chdir(project_path) - - client = TestClient(app) - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) - assert session_id is not None - - rms_executor = MagicMock(shutdown=MagicMock()) - rms_project = MagicMock(close=MagicMock()) - await add_rms_project_to_session(session_id, rms_executor, rms_project) - session = cast("ProjectSession", await get_fmu_session(session_id)) - assert session.rms_session is not None - - # Expire the RMS session only - expired_timestamp = datetime.now(UTC) - session.rms_session.expires_at = expired_timestamp - await update_fmu_session(session) - - # Assert RMS session removed and user session refreshed - session_expires_at = session.expires_at - response = client.patch(ROUTE) - session = cast("ProjectSession", await get_fmu_session(session_id)) - assert session.rms_session is None - assert session.expires_at > session_expires_at - - # Expire the user session only - await add_rms_project_to_session(session_id, rms_executor, rms_project) - updated_session = await get_fmu_session(session_id) - assert isinstance(updated_session, ProjectSession) - assert updated_session.rms_session is not None - updated_session.expires_at = expired_timestamp - await update_fmu_session(updated_session) - - # Assert session has been destroyed - response = client.patch(ROUTE) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json()["detail"] == "No active session found" - with pytest.raises(SessionNotFoundError): - await get_fmu_session(session_id) - - -async def test_refresh_session_requires_cookie() -> None: - """Tests that refreshing session with a missing cookie returns 401.""" - client = TestClient(app) - response = client.patch(ROUTE) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json()["detail"] == "No active session found" - - # GET session/ # From 40384ff78142277fdb61c44f6b98b8455af4f895 Mon Sep 17 00:00:00 2001 From: Muhammad Gibran Alfarizi Date: Mon, 30 Mar 2026 11:12:44 +0200 Subject: [PATCH 3/4] MAINT: Adapt to new fmu init behavior --- tests/conftest.py | 44 ++- tests/test_session_manager.py | 162 ++++++----- tests/test_v1/test_deps.py | 74 +++-- tests/test_v1/test_project.py | 10 +- tests/test_v1/test_session.py | 9 +- uv.lock | 520 +++++++++++++++++----------------- 6 files changed, 444 insertions(+), 375 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d249154..6115659 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,11 @@ ) from fmu.settings import ProjectFMUDirectory from fmu.settings._fmu_dir import UserFMUDirectory -from fmu.settings._init import init_fmu_directory, init_user_fmu_directory +from fmu.settings._init import ( + REQUIRED_FMU_PROJECT_SUBDIRS, + init_fmu_directory, + init_user_fmu_directory, +) from pytest import MonkeyPatch from fmu_settings_api.__main__ import app @@ -33,6 +37,31 @@ ) +@pytest.fixture +def make_fmu_project_root() -> Callable[[Path], Path]: + """Return a helper that prepares a valid FMU project root for a test path.""" + + def _make_fmu_project_root(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + for dir_name in REQUIRED_FMU_PROJECT_SUBDIRS: + (path / dir_name).mkdir(parents=True, exist_ok=True) + return path + + return _make_fmu_project_root + + +@pytest.fixture +def init_project_fmu_directory( + make_fmu_project_root: Callable[[Path], Path], +) -> Callable[[Path], ProjectFMUDirectory]: + """Return a helper that initializes a valid project .fmu directory.""" + + def _init_project_fmu_directory(path: Path) -> ProjectFMUDirectory: + return init_fmu_directory(make_fmu_project_root(path)) + + return _init_project_fmu_directory + + @pytest.fixture def create_stratigraphic_unit() -> Callable[..., StratigraphicUnit]: """Fixture that returns a helper function to create StratigraphicUnit. @@ -157,9 +186,11 @@ def mock_token() -> str: @pytest.fixture -def fmu_dir(tmp_path: Path) -> ProjectFMUDirectory: +def fmu_dir( + tmp_path: Path, make_fmu_project_root: Callable[[Path], Path] +) -> ProjectFMUDirectory: """Creates a .fmu directory in a tmp path.""" - return init_fmu_directory(tmp_path) + return init_fmu_directory(make_fmu_project_root(tmp_path)) @pytest.fixture @@ -197,7 +228,11 @@ def user_fmu_dir_no_permissions(fmu_dir_path: Path) -> Generator[Path]: @pytest.fixture -def tmp_path_mocked_home(tmp_path: Path, monkeypatch: MonkeyPatch) -> Generator[Path]: +def tmp_path_mocked_home( + tmp_path: Path, + monkeypatch: MonkeyPatch, + make_fmu_project_root: Callable[[Path], Path], +) -> Generator[Path]: """Mocks Path.home() for routes that depend on UserFMUDirectory. This mocks the user .fmu into tmp_path/home/.fmu. @@ -207,6 +242,7 @@ def tmp_path_mocked_home(tmp_path: Path, monkeypatch: MonkeyPatch) -> Generator[ """ mocked_user_home = tmp_path / "home" mocked_user_home.mkdir() + make_fmu_project_root(tmp_path) with patch("pathlib.Path.home", return_value=mocked_user_home): monkeypatch.chdir(tmp_path) yield tmp_path diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index d00d577..7044d31 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -1,12 +1,14 @@ """Tests the SessionManager functionality.""" +from collections.abc import Callable from copy import deepcopy from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from fmu.settings._init import init_fmu_directory, init_user_fmu_directory +from fmu.settings import ProjectFMUDirectory +from fmu.settings._init import init_user_fmu_directory from fmu.settings._resources.lock_manager import LockError from pydantic import SecretStr @@ -120,15 +122,17 @@ async def test_destroy_session( assert len(session_manager.storage) == 0 async def test_destroy_session_releases_project_lock( - self, session_manager: SessionManager, tmp_path_mocked_home: Path + self, + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that destroying a session with a project releases the project lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await session_manager.create_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() project_fmu_dir._lock = mock_lock @@ -141,15 +145,17 @@ async def test_destroy_session_releases_project_lock( mock_lock.release.assert_called_once() async def test_destroy_session_handles_lock_release_exceptions( - self, session_manager: SessionManager, tmp_path_mocked_home: Path + self, + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that session destruction handles lock release exceptions gracefully.""" user_fmu_dir = init_user_fmu_directory() session_id = await session_manager.create_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.release.side_effect = Exception("Lock release failed") @@ -166,6 +172,7 @@ async def test_destroy_session_closes_rms_project( self, session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -174,8 +181,7 @@ async def test_destroy_session_closes_rms_project( session_id = await session_manager.create_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) @@ -427,15 +433,16 @@ async def test_refresh_rms_session_with_open_rms_project() -> None: async def test_add_fmu_project_to_session_acquires_lock( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that adding an FMU project to a session acquires the lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() project_fmu_dir._lock = mock_lock @@ -447,19 +454,19 @@ async def test_add_fmu_project_to_session_acquires_lock( async def test_add_fmu_project_to_session_releases_previous_lock( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that adding a new project releases the previous project's lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) + project1_fmu_dir = init_project_fmu_directory(project1_path) project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) + project2_fmu_dir = init_project_fmu_directory(project2_path) mock_lock1 = Mock() mock_lock2 = Mock() @@ -476,22 +483,22 @@ async def test_add_fmu_project_to_session_releases_previous_lock( async def test_add_fmu_project_to_session_handles_previous_lock_release_error( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests handling exception when releasing previous lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) + project1_fmu_dir = init_project_fmu_directory(project1_path) mock_lock1 = Mock() project1_fmu_dir._lock = mock_lock1 project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) + project2_fmu_dir = init_project_fmu_directory(project2_path) mock_lock2 = Mock() project2_fmu_dir._lock = mock_lock2 @@ -513,6 +520,7 @@ async def test_add_fmu_project_to_session_handles_previous_lock_release_error( async def test_add_fmu_project_to_session_closes_existing_rms( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -521,12 +529,10 @@ async def test_add_fmu_project_to_session_closes_existing_rms( session_id = await create_fmu_session(user_fmu_dir) project1_path = tmp_path_mocked_home / "test_project1" - project1_path.mkdir() - project1_fmu_dir = init_fmu_directory(project1_path) + project1_fmu_dir = init_project_fmu_directory(project1_path) project2_path = tmp_path_mocked_home / "test_project2" - project2_path.mkdir() - project2_fmu_dir = init_fmu_directory(project2_path) + project2_fmu_dir = init_project_fmu_directory(project2_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project1_fmu_dir) @@ -551,15 +557,16 @@ async def test_add_fmu_project_to_session_closes_existing_rms( async def test_add_fmu_project_to_session_handles_lock_error_gracefully( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that LockError is gracefully handled in add_fmu_project_to_session.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.acquire.side_effect = LockError("Project is locked by another process") @@ -612,15 +619,16 @@ async def test_add_access_token_to_session_with_invalid_token( async def test_try_acquire_project_lock_acquires_when_not_held( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that try_acquire_project_lock acquires the lock when not already held.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_acquire_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -639,15 +647,16 @@ async def test_try_acquire_project_lock_acquires_when_not_held( async def test_try_acquire_project_lock_records_acquire_error( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that lock acquire failures are captured by try_acquire_project_lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_acquire_error_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -680,15 +689,16 @@ async def test_try_acquire_project_lock_requires_project_session( async def test_try_acquire_project_lock_handles_is_acquired_error( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that try_acquire_project_lock tolerates lock status errors.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_status_error_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.side_effect = LockError("status failed") @@ -708,15 +718,16 @@ async def test_try_acquire_project_lock_handles_is_acquired_error( async def test_release_project_lock_releases_when_held( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that release_project_lock releases the lock when held.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -749,15 +760,16 @@ async def test_release_project_lock_requires_project_session( async def test_release_project_lock_records_release_error( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that lock release failures are captured by release_project_lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_error_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -776,15 +788,16 @@ async def test_release_project_lock_records_release_error( async def test_release_project_lock_skips_when_not_held( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that release_project_lock does not release when lock is not held.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_release_not_held_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -803,15 +816,16 @@ async def test_release_project_lock_skips_when_not_held( async def test_refresh_project_lock_refreshes_when_held( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that refresh_project_lock refreshes the lock when held.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_refresh_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -844,15 +858,16 @@ async def test_refresh_project_lock_requires_project_session( async def test_refresh_project_lock_records_refresh_error( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that lock refresh failures are captured by refresh_project_lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_refresh_error_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -871,15 +886,16 @@ async def test_refresh_project_lock_records_refresh_error( async def test_refresh_project_lock_skips_when_not_held( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that refresh_project_lock does not refresh when lock is not held.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "lock_refresh_not_held_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -898,15 +914,16 @@ async def test_refresh_project_lock_skips_when_not_held( async def test_remove_fmu_project_from_session_releases_lock( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that removing an FMU project from a session releases the lock.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() project_fmu_dir._lock = mock_lock @@ -920,15 +937,16 @@ async def test_remove_fmu_project_from_session_releases_lock( async def test_remove_fmu_project_from_session_handles_lock_release_exception( - session_manager: SessionManager, tmp_path_mocked_home: Path + session_manager: SessionManager, + tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that removing an FMU project handles lock release exceptions gracefully.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.release.side_effect = Exception("Lock release failed") @@ -963,6 +981,7 @@ async def test_remove_fmu_project_from_session_with_regular_session( async def test_add_rms_project_to_session_success( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -971,8 +990,7 @@ async def test_add_rms_project_to_session_success( session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) @@ -1011,6 +1029,7 @@ async def test_add_rms_project_to_session_no_project_session( async def test_add_rms_project_to_session_closes_existing( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -1019,8 +1038,7 @@ async def test_add_rms_project_to_session_closes_existing( session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_rms_executor_existing = MagicMock(shutdown=MagicMock()) mock_rms_project_existing = MagicMock(close=MagicMock()) @@ -1056,6 +1074,7 @@ async def test_add_rms_project_to_session_closes_existing( async def test_remove_rms_project_from_session_success( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -1064,8 +1083,7 @@ async def test_remove_rms_project_from_session_success( session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) @@ -1099,6 +1117,7 @@ async def test_remove_rms_project_from_session_no_project_session( async def test_remove_rms_project_from_session_closes_project( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -1107,8 +1126,7 @@ async def test_remove_rms_project_from_session_closes_project( session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) @@ -1128,6 +1146,7 @@ async def test_remove_rms_project_from_session_closes_project( async def test_remove_fmu_project_from_session_closes_rms_project( session_manager: SessionManager, tmp_path_mocked_home: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], mock_rms_executor: MagicMock, mock_rms_project: MagicMock, ) -> None: @@ -1136,8 +1155,7 @@ async def test_remove_fmu_project_from_session_closes_rms_project( session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) with patch("fmu_settings_api.session.session_manager", session_manager): await add_fmu_project_to_session(session_id, project_fmu_dir) diff --git a/tests/test_v1/test_deps.py b/tests/test_v1/test_deps.py index 7a0242c..06c12ec 100644 --- a/tests/test_v1/test_deps.py +++ b/tests/test_v1/test_deps.py @@ -1,5 +1,6 @@ """Tests dependencies (middleware).""" +from collections.abc import Callable from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import Mock, create_autospec, patch @@ -8,7 +9,8 @@ import pytest from fastapi import Cookie, HTTPException, status from fastapi.testclient import TestClient -from fmu.settings._init import init_fmu_directory, init_user_fmu_directory +from fmu.settings import ProjectFMUDirectory +from fmu.settings._init import init_user_fmu_directory from fmu.settings._resources.lock_manager import LockError from pydantic import SecretStr @@ -215,15 +217,16 @@ async def test_ensure_user_fmu_directory_outer_general_error() -> None: async def test_check_write_permissions_project_not_acquired( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Test that check_write_permissions raises HTTPException when not acquired.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -240,15 +243,16 @@ async def test_check_write_permissions_project_not_acquired( async def test_check_write_permissions_not_locked( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Test that check_write_permissions raises 423 when project is not locked.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_locked.return_value = False @@ -265,15 +269,16 @@ async def test_check_write_permissions_not_locked( async def test_check_write_permissions_permission_error( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Test that check_write_permissions raises 403 on PermissionError.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_locked.side_effect = PermissionError("Permission denied") @@ -289,15 +294,16 @@ async def test_check_write_permissions_permission_error( async def test_check_write_permissions_file_not_found_error( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Test that check_write_permissions raises 423 on FileNotFoundError.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_locked.side_effect = FileNotFoundError("Lock file not found") @@ -328,15 +334,16 @@ async def test_get_session_service_returns_session_service( async def test_get_project_session_service_returns_session_service( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that get_project_session_service returns a SessionService instance.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) await add_fmu_project_to_session(session_id, project_fmu_dir) session = await get_fmu_session(session_id) @@ -351,15 +358,16 @@ async def test_get_project_session_service_returns_session_service( async def test_get_project_service_returns_project_service( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that get_project_service returns a ProjectService instance.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) await add_fmu_project_to_session(session_id, project_fmu_dir) session = await get_fmu_session(session_id) @@ -373,15 +381,16 @@ async def test_get_project_service_returns_project_service( async def test_refresh_lock_dep_refreshes_lock_when_acquired( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that RefreshLockDep refreshes the lock when it is acquired.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -395,15 +404,16 @@ async def test_refresh_lock_dep_refreshes_lock_when_acquired( async def test_refresh_lock_dep_does_nothing_when_not_acquired( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that RefreshLockDep does nothing when lock is not acquired.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = False @@ -417,15 +427,16 @@ async def test_refresh_lock_dep_does_nothing_when_not_acquired( async def test_refresh_lock_dep_handles_lock_error( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that RefreshLockDep handles lock errors gracefully.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True @@ -442,15 +453,16 @@ async def test_refresh_lock_dep_handles_lock_error( async def test_refresh_lock_dep_handles_permission_error( - tmp_path_mocked_home: Path, session_manager: SessionManager + tmp_path_mocked_home: Path, + session_manager: SessionManager, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that RefreshLockDep swallows PermissionError exceptions.""" user_fmu_dir = init_user_fmu_directory() session_id = await create_fmu_session(user_fmu_dir) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() - project_fmu_dir = init_fmu_directory(project_path) + project_fmu_dir = init_project_fmu_directory(project_path) mock_lock = Mock() mock_lock.is_acquired.return_value = True diff --git a/tests/test_v1/test_project.py b/tests/test_v1/test_project.py index 2637f3d..140e42c 100644 --- a/tests/test_v1/test_project.py +++ b/tests/test_v1/test_project.py @@ -633,16 +633,16 @@ async def test_post_fmu_directory_exists( async def test_post_fmu_directory_changes_session_instance( - client_with_session: TestClient, session_tmp_path: Path + client_with_session: TestClient, + session_tmp_path: Path, + init_project_fmu_directory: Callable[[Path], ProjectFMUDirectory], ) -> None: """Tests that posting a new project changes the instance in the session.""" project_x = session_tmp_path / "project_x" - project_x.mkdir() - x_fmu_dir = init_fmu_directory(project_x) + x_fmu_dir = init_project_fmu_directory(project_x) project_y = session_tmp_path / "project_y" - project_y.mkdir() - y_fmu_dir = init_fmu_directory(project_y) + y_fmu_dir = init_project_fmu_directory(project_y) # Check Project X response = client_with_session.post(ROUTE, json={"path": str(project_x)}) diff --git a/tests/test_v1/test_session.py b/tests/test_v1/test_session.py index 02eef65..0fdb88f 100644 --- a/tests/test_v1/test_session.py +++ b/tests/test_v1/test_session.py @@ -1,6 +1,7 @@ """Tests the /api/v1/session routes.""" import shutil +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import cast @@ -287,12 +288,13 @@ async def test_post_session_returns_conflict_for_existing_valid_session( assert updated_session == session -async def test_post_session_returns_conflict_for_existing_project_session( +async def test_post_session_returns_conflict_for_existing_project_session( # noqa: PLR0913 tmp_path_mocked_home: Path, client_with_session: TestClient, session_manager: SessionManager, mock_token: str, monkeypatch: MonkeyPatch, + make_fmu_project_root: Callable[[Path], Path], ) -> None: """Tests creating a new session returns 409 for an existing project session. @@ -302,7 +304,7 @@ async def test_post_session_returns_conflict_for_existing_project_session( even if the current working directory changes. """ project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() + make_fmu_project_root(project_path) init_fmu_directory(project_path) monkeypatch.chdir(project_path) @@ -359,12 +361,13 @@ async def test_post_session_handles_lock_conflicts( mock_token: str, session_manager: SessionManager, monkeypatch: MonkeyPatch, + make_fmu_project_root: Callable[[Path], Path], ) -> None: """Tests that session creation handles lock conflicts gracefully.""" client = TestClient(app) project_path = tmp_path_mocked_home / "test_project" - project_path.mkdir() + make_fmu_project_root(project_path) init_fmu_directory(project_path) monkeypatch.chdir(project_path) diff --git a/uv.lock b/uv.lock index 5a9506f..1b76d17 100644 --- a/uv.lock +++ b/uv.lock @@ -30,15 +30,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -143,101 +143,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] @@ -256,7 +256,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -265,9 +265,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] @@ -284,19 +284,19 @@ wheels = [ [[package]] name = "fmu-datamodels" -version = "0.20.1" +version = "0.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/b4/358b9a25761396c2438680a2d0d5b6d61772036cdc45ef7f8e114353b7e2/fmu_datamodels-0.20.1.tar.gz", hash = "sha256:96ccd263aada8529eb051ab5208746836a2db90b2a84b220a01a5da1504fe83b", size = 385938, upload-time = "2026-03-12T09:49:24.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/1e/9fd3a3ff803bb1753d0894c3c6ab5eb457fd56fb7a68757125560af14ad9/fmu_datamodels-0.20.2.tar.gz", hash = "sha256:880e021973a524f47f18d20c6e5bbeef31de82dbaa34116742ae91ab2fbc3156", size = 387910, upload-time = "2026-03-23T12:08:03.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/86/32ed1559bfba669ec1a8ffb2b1d6af43125b541a345a2f998cee2406d277/fmu_datamodels-0.20.1-py3-none-any.whl", hash = "sha256:63310b7043e64642384de7231716fa2ca88292814438fff7675fad09f171d116", size = 52651, upload-time = "2026-03-12T09:49:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/ef/74/a185697c4152b910f71dea691681a96f01ba1fde3f65af18ed3517710202/fmu_datamodels-0.20.2-py3-none-any.whl", hash = "sha256:872884dca1fbd762d55c8434fd59565e41095a93eb3bc1bf8622879abc3c67c2", size = 52759, upload-time = "2026-03-23T12:08:01.305Z" }, ] [[package]] name = "fmu-settings" -version = "0.24.0" +version = "0.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -306,9 +306,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/1d/53b6ad8b675eb8b994bc1f5efc333615816331257e6f266b1359e6f56200/fmu_settings-0.24.0.tar.gz", hash = "sha256:6df7cfc6ede21f860025ec46df4d708dfec5573e22f2de6fa2b99c2447249f3c", size = 136544, upload-time = "2026-03-12T15:47:36.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/de/071f322506fafb5231e7429df57ef16810eb699af110349817b7fb5ceb7d/fmu_settings-0.28.0.tar.gz", hash = "sha256:2e043bb83d209761835ea4c505bd1ee5605272ee26778b7f399e7d154691d5ca", size = 144218, upload-time = "2026-03-26T13:25:28.695Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/0f/a47c967f1315778ad59157c39c941813f90ce28719d80813e4a058bc5a2a/fmu_settings-0.24.0-py3-none-any.whl", hash = "sha256:7907904cde469f38070c384ed6849dfaff6fec67faee176791718a00374e62c0", size = 51897, upload-time = "2026-03-12T15:47:35.079Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/aa1cc64a5fdacedfbc159686a57b8f3177b73bd18caa25e569acf096df73/fmu_settings-0.28.0-py3-none-any.whl", hash = "sha256:671b580583412e0354d9df840913a2b6f5d13174f5797d95251cb290ede6a705", size = 57094, upload-time = "2026-03-26T13:25:26.699Z" }, ] [[package]] @@ -391,15 +391,15 @@ wheels = [ [[package]] name = "fmu-settings-gui" -version = "0.4.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/67/ed0dacdb607c70dc3f591ff698b727bae9768f59fec5e16d7283252153e6/fmu_settings_gui-0.4.0.tar.gz", hash = "sha256:a163f7345a8331736ea85c4f84e1a3ba96acad684eb9bac75da4acedf05decd1", size = 704663, upload-time = "2026-01-06T06:48:38.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/55/50519e3a3557022bd9290f059ea7e763e36538cebb26f99d7fb7f6bfa1fa/fmu_settings_gui-0.7.0.tar.gz", hash = "sha256:9da8f84526deaf179205d321278babbc6041068c08a7fc4d48b3f58a6e8bede8", size = 642993, upload-time = "2026-03-19T09:29:43.391Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/1f/3ceb55ae4ca84c8bda0e0aafa45d7a089c70a23839611fb06c898df88a40/fmu_settings_gui-0.4.0-py3-none-any.whl", hash = "sha256:a889c5efd37404fffe04130feb58b4d5197db3c1dff700aea84b237786b95afb", size = 472393, upload-time = "2026-01-06T06:48:37.06Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/fd113cdf2d579fdf2fe04947dcc844a2f1a7abf405c8b503739f04bc4a3f/fmu_settings_gui-0.7.0-py3-none-any.whl", hash = "sha256:c99ee7c8087e1a8d3f32dd1baeb75734885d7257c549d0a081a9588c71a549db", size = 450280, upload-time = "2026-03-19T09:29:41.933Z" }, ] [[package]] @@ -601,81 +601,81 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.2" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, - { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, - { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, - { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, - { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, - { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, - { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, - { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, - { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, - { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, - { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, - { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] [[package]] @@ -888,11 +888,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -926,16 +926,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1191,27 +1191,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -1249,15 +1249,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -1271,56 +1271,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -1349,14 +1349,14 @@ wheels = [ [[package]] name = "types-requests" -version = "2.32.4.20260107" +version = "2.33.0.20260327" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/5f/2e3dbae6e21be6ae026563bad96cbf76602d73aa85ea09f13419ddbdabb4/types_requests-2.33.0.20260327.tar.gz", hash = "sha256:f4f74f0b44f059e3db420ff17bd1966e3587cdd34062fe38a23cda97868f8dd8", size = 23804, upload-time = "2026-03-27T04:23:38.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/951e733616c92cb96b57554746d2f65f4464d080cc2cc093605f897aba89/types_requests-2.33.0.20260327-py3-none-any.whl", hash = "sha256:fde0712be6d7c9a4d490042d6323115baf872d9a71a22900809d0432de15776e", size = 20737, upload-time = "2026-03-27T04:23:37.813Z" }, ] [[package]] From 44bdff244fb1e6dc7c1c4ff70ba7811f20586a9a Mon Sep 17 00:00:00 2001 From: Muhammad Gibran Alfarizi Date: Mon, 30 Mar 2026 12:48:55 +0200 Subject: [PATCH 4/4] MAINT: Let POST /session renew existing session --- src/fmu_settings_api/session.py | 36 ++++++ src/fmu_settings_api/v1/responses.py | 6 +- src/fmu_settings_api/v1/routes/session.py | 26 +++-- tests/test_session_manager.py | 21 ++++ tests/test_v1/test_session.py | 134 +++++++++++++--------- 5 files changed, 159 insertions(+), 64 deletions(-) diff --git a/src/fmu_settings_api/session.py b/src/fmu_settings_api/session.py index 0c12a5c..971772a 100644 --- a/src/fmu_settings_api/session.py +++ b/src/fmu_settings_api/session.py @@ -182,6 +182,34 @@ async def create_session( return session_id + async def renew_session( + self: Self, + session_id: str, + expire_seconds: int = settings.SESSION_EXPIRE_SECONDS, + ) -> Session | ProjectSession: + """Renews an existing session by rotating its id and expiration. + + Returns: + The renewed session instance stored under its new session id + + Raises: + SessionNotFoundError: If the session does not exist + """ + session = await self._retrieve_session(session_id) + if session is None: + raise SessionNotFoundError("No active session found") + + now = datetime.now(UTC) + new_session_id = str(uuid4()) + session.id = new_session_id + session.created_at = now + session.expires_at = now + timedelta(seconds=expire_seconds) + session.last_accessed = now + + del self.storage[session_id] + await self._store_session(new_session_id, session) + return session + async def get_session(self: Self, session_id: str) -> Session | ProjectSession: """Get the session data for a session id. @@ -223,6 +251,14 @@ async def get_fmu_session(session_id: str) -> Session | ProjectSession: return await session_manager.get_session(session_id) +async def renew_fmu_session( + session_id: str, + expire_seconds: int = settings.SESSION_EXPIRE_SECONDS, +) -> Session | ProjectSession: + """Renews a session in the session manager.""" + return await session_manager.renew_session(session_id, expire_seconds) + + async def update_fmu_session(session: Session | ProjectSession) -> None: """Update a session in the session manager.""" await session_manager.update_session(session.id, session) diff --git a/src/fmu_settings_api/v1/responses.py b/src/fmu_settings_api/v1/responses.py index 756f8c8..a365e1e 100644 --- a/src/fmu_settings_api/v1/responses.py +++ b/src/fmu_settings_api/v1/responses.py @@ -66,15 +66,11 @@ def inline_add_response( 409, dedent( """ - Occurs in two cases: - - - When attempting to create a session when one already exists - - When trying to create a user .fmu directory, but it already + Occurs when trying to create a user .fmu directory, but it already exists. Typically means that .fmu exists as a file. """ ), [ - {"detail": "A session already exists"}, { "detail": ( "User .fmu already exists but is invalid (i.e. is not a directory)" diff --git a/src/fmu_settings_api/v1/routes/session.py b/src/fmu_settings_api/v1/routes/session.py index 48a6d58..83623e5 100644 --- a/src/fmu_settings_api/v1/routes/session.py +++ b/src/fmu_settings_api/v1/routes/session.py @@ -22,6 +22,8 @@ add_fmu_project_to_session, create_fmu_session, get_fmu_session, + get_rms_session_expiration, + renew_fmu_session, ) from fmu_settings_api.v1.responses import ( CreateSessionResponses, @@ -60,13 +62,13 @@ .fmu directory exists by creating it if it does not. If a valid session already exists when POSTing to this route, the - request will return a 409 conflict response and no new session will - be created. + existing session will be renewed with a new expiry date and a new + session cookie value. After creating the session, the application will attempt to find the nearest project .fmu directory above the current working directory and add it to the session if found. If not found, no project will be - associated. + associated. Renewing an existing session keeps its current state. The session cookie set by this route is required for all other routes. Sessions are not persisted when the API is shut down. @@ -84,10 +86,20 @@ async def post_session( """Creates a new user session.""" if fmu_settings_session: try: - await get_fmu_session(fmu_settings_session) - raise HTTPException( - status_code=409, - detail="A session already exists", + session = await renew_fmu_session(fmu_settings_session) + response.set_cookie( + key=settings.SESSION_COOKIE_KEY, + value=session.id, + httponly=True, + secure=False, + samesite="lax", + ) + return SessionResponse( + id=session.id, + created_at=session.created_at, + expires_at=session.expires_at, + rms_expires_at=await get_rms_session_expiration(session.id), + last_accessed=session.last_accessed, ) except SessionNotFoundError: pass diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index 7044d31..0f83495 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -33,6 +33,7 @@ release_project_lock, remove_fmu_project_from_session, remove_rms_project_from_session, + renew_fmu_session, session_manager, try_acquire_project_lock, update_fmu_session, @@ -309,6 +310,26 @@ async def test_update_fmu_session( assert updated_session == session_manager.storage[session_id] +async def test_renew_fmu_session( + session_manager: SessionManager, tmp_path_mocked_home: Path +) -> None: + """Tests renewing a session rotates its id and expiration.""" + user_fmu_dir = init_user_fmu_directory() + session_id = await create_fmu_session(user_fmu_dir) + session = await get_fmu_session(session_id) + original_created_at = session.created_at + original_expires_at = session.expires_at + + renewed_session = await renew_fmu_session(session_id) + + assert renewed_session.id != session_id + assert renewed_session.created_at > original_created_at + assert renewed_session.expires_at > original_expires_at + assert renewed_session.user_fmu_directory == user_fmu_dir + assert session_id not in session_manager.storage + assert renewed_session.id in session_manager.storage + + async def test_destroy_fmu_session_if_expired_with_expired_session() -> None: """Tests that the session is destroyed and the RMS session is removed. diff --git a/tests/test_v1/test_session.py b/tests/test_v1/test_session.py index 0fdb88f..8e028b9 100644 --- a/tests/test_v1/test_session.py +++ b/tests/test_v1/test_session.py @@ -25,6 +25,7 @@ SessionManager, SessionNotFoundError, add_rms_project_to_session, + destroy_fmu_session_if_expired, get_fmu_session, update_fmu_session, ) @@ -232,22 +233,20 @@ async def test_post_session_destroy_existing_expired_session( session.expires_at = expired_timestamp await update_fmu_session(session) - # Post new session and assert that destroy was called + # Post new session and assert that the expired session is removed first with patch( "fmu_settings_api.deps.session.destroy_fmu_session_if_expired", new_callable=AsyncMock, + wraps=destroy_fmu_session_if_expired, ) as mock_destroy_session: response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "A session already exists"} - mock_destroy_session.assert_called_once_with(session_id) + assert response.status_code == status.HTTP_200_OK, response.json() + mock_destroy_session.assert_awaited_once_with(session_id) - # Expired session should be destroyed and new valid session should be created - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) new_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) assert new_session_id is not None - assert new_session_id is not session_id + assert new_session_id != session_id with pytest.raises(SessionNotFoundError, match="No active session found"): await get_fmu_session(session_id) @@ -257,17 +256,17 @@ async def test_post_session_destroy_existing_expired_session( assert new_session.expires_at > expired_timestamp -async def test_post_session_returns_conflict_for_existing_valid_session( +async def test_post_session_renews_existing_valid_session( tmp_path_mocked_home: Path, client_with_session: TestClient, session_manager: SessionManager, mock_token: str, ) -> None: - """Tests creating a new session returns 409 when one already exists. + """Tests POSTing to session renews an existing valid user session. Scenario: A valid user session with the session_id provided already exists. - The existing session should be kept unchanged and the API should return - a conflict instead of creating a new session. + The existing session should be renewed with a new session id and expiry + while keeping the current session state. """ client = TestClient(app) response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) @@ -278,17 +277,29 @@ async def test_post_session_returns_conflict_for_existing_valid_session( session = await get_fmu_session(session_id) assert session.id == session_id assert isinstance(session, Session) + original_created_at = session.created_at + original_expires_at = session.expires_at - # Posting again should keep the current session and return a conflict response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "A session already exists"} + assert response.status_code == status.HTTP_200_OK + + renewed_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert renewed_session_id is not None + assert renewed_session_id != session_id + + with pytest.raises(SessionNotFoundError, match="No active session found"): + await get_fmu_session(session_id) - updated_session = await get_fmu_session(session_id) - assert updated_session == session + renewed_session = await get_fmu_session(renewed_session_id) + assert isinstance(renewed_session, Session) + assert renewed_session.id == renewed_session_id + assert renewed_session.user_fmu_directory.path == session.user_fmu_directory.path + assert renewed_session.access_tokens == session.access_tokens + assert renewed_session.created_at > original_created_at + assert renewed_session.expires_at > original_expires_at -async def test_post_session_returns_conflict_for_existing_project_session( # noqa: PLR0913 +async def test_post_session_renews_existing_project_session( # noqa: PLR0913 tmp_path_mocked_home: Path, client_with_session: TestClient, session_manager: SessionManager, @@ -296,12 +307,12 @@ async def test_post_session_returns_conflict_for_existing_project_session( # no monkeypatch: MonkeyPatch, make_fmu_project_root: Callable[[Path], Path], ) -> None: - """Tests creating a new session returns 409 for an existing project session. + """Tests POSTing to session renews an existing project session. Scenario: A valid project session with the session_id provided already - exists. The existing project session should be kept unchanged, no new - session should be created, and the attached project should not be swapped - even if the current working directory changes. + exists. The session should be renewed with a new session id while keeping + the attached project, access tokens, and RMS session unchanged even if the + current working directory changes. """ project_path = tmp_path_mocked_home / "test_project" make_fmu_project_root(project_path) @@ -318,42 +329,61 @@ async def test_post_session_returns_conflict_for_existing_project_session( # no assert session.id == session_id assert isinstance(session, ProjectSession) - # Posting again should not create a replacement session - with patch( - "fmu_settings_api.v1.routes.session.create_fmu_session", - new_callable=AsyncMock, - ) as mock_create_session: - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "A session already exists"} - mock_create_session.assert_not_called() - assert await get_fmu_session(session_id) == session + original_created_at = session.created_at + original_expires_at = session.expires_at + + with ( + patch( + "fmu_settings_api.v1.routes.session.create_fmu_session", + new_callable=AsyncMock, + ) as mock_create_session, + patch( + "fmu_settings_api.v1.routes.session.add_fmu_project_to_session", + new_callable=AsyncMock, + ) as mock_add_project_to_session, + ): + client.patch( + f"{ROUTE}/access_token", + json={"id": "smda_api", "key": "secret_token"}, + ) - # Assert session is kept the same when valid project session exists - client.patch( - f"{ROUTE}/access_token", - json={"id": "smda_api", "key": "secret_token"}, - ) + rms_executor = MagicMock(shutdown=MagicMock()) + rms_project = MagicMock(close=MagicMock()) + await add_rms_project_to_session(session_id, rms_executor, rms_project) - rms_executor = MagicMock(shutdown=MagicMock()) - rms_project = MagicMock(close=MagicMock()) - await add_rms_project_to_session(session_id, rms_executor, rms_project) + updated_session = cast("ProjectSession", await get_fmu_session(session_id)) + assert updated_session.rms_session is not None + assert updated_session.rms_session == RmsSession( + rms_executor, rms_project, updated_session.rms_session.expires_at + ) - updated_session = cast("ProjectSession", await get_fmu_session(session_id)) - assert updated_session.rms_session is not None - assert updated_session.rms_session == RmsSession( - rms_executor, rms_project, updated_session.rms_session.expires_at - ) + different_path = tmp_path_mocked_home / "different_project" + different_path.mkdir() + monkeypatch.chdir(different_path) - different_path = tmp_path_mocked_home / "different_project" - different_path.mkdir() - monkeypatch.chdir(different_path) + response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) + assert response.status_code == status.HTTP_200_OK + mock_create_session.assert_not_awaited() + mock_add_project_to_session.assert_not_awaited() - # Changing cwd should still not replace the existing project session - response = client.post(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}) - assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "A session already exists"} - assert updated_session == await get_fmu_session(session_id) + renewed_session_id = response.cookies.get(settings.SESSION_COOKIE_KEY) + assert renewed_session_id is not None + assert renewed_session_id != session_id + + with pytest.raises(SessionNotFoundError, match="No active session found"): + await get_fmu_session(session_id) + + renewed_session = cast("ProjectSession", await get_fmu_session(renewed_session_id)) + assert isinstance(renewed_session, ProjectSession) + assert ( + renewed_session.project_fmu_directory.path == session.project_fmu_directory.path + ) + assert renewed_session.access_tokens.smda_api is not None + assert renewed_session.rms_session is not None + assert renewed_session.rms_session.executor is rms_executor + assert renewed_session.rms_session.project is rms_project + assert renewed_session.created_at > original_created_at + assert renewed_session.expires_at > original_expires_at async def test_post_session_handles_lock_conflicts(