From f16f18f9125ec009d4b2723d9008fec5aaf8f1a2 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Sat, 21 Feb 2026 17:51:11 +0100 Subject: [PATCH 01/12] Minor correction on tox setup --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 10b161c..2219868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ env_list = [ [tool.tox.env_run_base] description = "Run test under {base_python}" -set_env.COV_PATH = { replace = "env", name = "COVERAGE_FILE", default = "reports/coverage-{env:OS_NAME:local}-{env_name}.xml" } +set_env.COV_PATH = { replace = "env", name = "COV_PATH", default = "reports/coverage-{env:OS_NAME:local}-{env_name}.xml" } commands = [ ["uv", "sync", "--group", "test", "--active", "--no-config"], From 2227d7c915c26f3e2b0c7e69997033705c198400 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Tue, 24 Feb 2026 23:49:02 +0100 Subject: [PATCH 02/12] Refactor Context to Scope and create Context class that manages Scopes --- cloudisk/globals.py | 20 +++++++++++---- cloudisk/tools/__init__.py | 1 + cloudisk/tools/context.py | 28 ++++++++++---------- cloudisk/tools/scope.py | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 cloudisk/tools/scope.py diff --git a/cloudisk/globals.py b/cloudisk/globals.py index 21ec492..c470c81 100644 --- a/cloudisk/globals.py +++ b/cloudisk/globals.py @@ -2,10 +2,20 @@ from pathlib import Path -from cloudisk.tools.context import Context -from cloudisk.tools.settings import Settings +from cloudisk.tools import Context, Scope +from cloudisk.vars import CLOUDISK_DB_PATH -context = Context() +root_scope = Scope( + "root", + engine_path=CLOUDISK_DB_PATH, + settings_module="cloudisk.tools._settings.default", +) -settings = Settings() -settings.set_default(STATIC_PATH=str(Path(__file__).parent.parent / "templates" / "js")) +root_scope.settings.set_default( + STATIC_PATH=str(Path(__file__).parent.parent / "templates" / "js") +) + +# TMP +settings = root_scope.settings + +context = Context(scopes=[root_scope]) diff --git a/cloudisk/tools/__init__.py b/cloudisk/tools/__init__.py index f83fdb6..ca9a734 100644 --- a/cloudisk/tools/__init__.py +++ b/cloudisk/tools/__init__.py @@ -1,2 +1,3 @@ from .context import Context as Context from .settings import Settings as Settings +from .scope import Scope as Scope diff --git a/cloudisk/tools/context.py b/cloudisk/tools/context.py index e109b6a..8e70697 100644 --- a/cloudisk/tools/context.py +++ b/cloudisk/tools/context.py @@ -1,21 +1,23 @@ -from sqlalchemy import create_engine +from typing import Optional -from cloudisk.vars import CLOUDISK_DB_PATH +from cloudisk.tools.scope import Scope class Context: - def __init__(self): # noqa: D107 - self._engine = None + def __init__(self, scopes: Optional[dict[str, Scope] | list[Scope]]): # noqa: D107 + if scopes is None: + scopes = {} - def reset(self): - self._engine = None + if isinstance(scopes, list): + scopes = {scope.name: scope for scope in scopes} - @property - def engine(self): - if self._engine is None: - self._engine = self._create_engine() + self.scopes = scopes - return self._engine + def __getattr__(self, name: str): # noqa: D105 + return self.scopes.get(name) - def _create_engine(self): - return create_engine(f"sqlite:///{CLOUDISK_DB_PATH}") + def add_scope(self, scope: Scope): + self.scopes[scope.name] = scope + + def drop_scope(self, name: str): + del self.scope[name] diff --git a/cloudisk/tools/scope.py b/cloudisk/tools/scope.py new file mode 100644 index 0000000..f44c27b --- /dev/null +++ b/cloudisk/tools/scope.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Optional + +from sqlalchemy import create_engine + +from cloudisk.tools.settings import Settings + + +class Scope: + def __init__( # noqa: D107 + self, + name: str, + *, + engine_path: Optional[Path] = None, + settings_module: Optional[str] = None, + ): + self.name = name + self.engine_path = engine_path + self.settings_module = settings_module + + self._engine = None + self._settings = None + + @property + def engine(self): + if self._engine is None: + self.set_engine() + + return self._engine + + @property + def settings(self): + if self._settings is None: + self._settings = Settings(self.settings_module) + + return self._settings + + def set_engine(self, path: str | Path): + if path is None: + path = self.engine_path + + if isinstance(path, Path): + path = str(path) + + self._engine = self._create_engine(path) + + def cleanup(self): + self._engine.dispose() + self._engine = None + + def _create_engine(self, path: str): + return create_engine(f"sqlite:///{path}") From 30c60f17de0d6b9ea44c77a2e44dea657737b1a0 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Tue, 24 Feb 2026 23:54:54 +0100 Subject: [PATCH 03/12] Change how settings are generated and managed by default --- cloudisk/tools/_settings/default.py | 6 +++--- cloudisk/tools/settings.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cloudisk/tools/_settings/default.py b/cloudisk/tools/_settings/default.py index a5e0769..b0df802 100644 --- a/cloudisk/tools/_settings/default.py +++ b/cloudisk/tools/_settings/default.py @@ -5,7 +5,7 @@ ######################### # The path where the static files will be served from. -STATIC_PATH = "" +STATIC_PATH = ... ######################### @@ -13,7 +13,7 @@ ######################### # The email of the account that sends the account verifying email -EMAIL_FROM = "" +EMAIL_FROM = ... # The password of the account that sends the account verifying email -PASS_FROM = "" +PASS_FROM = ... diff --git a/cloudisk/tools/settings.py b/cloudisk/tools/settings.py index 118e197..7fa5fa1 100644 --- a/cloudisk/tools/settings.py +++ b/cloudisk/tools/settings.py @@ -58,10 +58,16 @@ def set_default(self, **kwargs): for key, value in kwargs.items(): self._check_key(key) - if self.module: - self.module.__dict__.setdefault(key, value) - else: + if not self.module: os.environ.setdefault(f"CLOUDISK_{key}", str(value)) + continue + + if key not in self.module.__dict__: + self.module.__dict__.setdefault(key, value) + continue + + if isinstance(self.module.__dict__[key], type(Ellipsis)): + self.module.__dict__[key] = value def clear_cache(self): """Clear cache for all functions and properties inside the instance.""" From 0a87ee1015b7be943efe824626d6c1be041904df Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Sat, 7 Mar 2026 22:54:32 +0100 Subject: [PATCH 04/12] Add the use command to change the used space --- cloudisk/cli/parser.py | 15 ++++++- cloudisk/db/models/space.py | 83 ++++++++++++++++++++++++++++++++++--- cloudisk/fs/commands.py | 31 +++++++++++--- cloudisk/vars.py | 2 + 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/cloudisk/cli/parser.py b/cloudisk/cli/parser.py index c6a3a1f..d39ef6f 100644 --- a/cloudisk/cli/parser.py +++ b/cloudisk/cli/parser.py @@ -10,6 +10,7 @@ link_path, list_spaces, unlink_path, + use_space, ) from cloudisk.http import server from cloudisk.vars import CLOUDISK_ROOT @@ -28,8 +29,8 @@ def init(): @app.command(help=f"Creates a new space inside '{CLOUDISK_ROOT}'") def create( name: Annotated[ - str, - typer.Option("--name", "-n", help="Name of the instance."), + Optional[str], + typer.Argument(help="Name of the instance."), ] = None, protect: Annotated[ Optional[bool], @@ -60,6 +61,16 @@ def create( create_space(name, protect) +@app.command(help=f"Use a space inside '{CLOUDISK_ROOT}'") +def use( + name: Annotated[ + str, + typer.Argument(help="Name of the instance."), + ], +): + use_space(name) + + @app.command(help="Lists all created spaces") def list(): list_spaces() diff --git a/cloudisk/db/models/space.py b/cloudisk/db/models/space.py index 42e78a1..1ce8dfc 100644 --- a/cloudisk/db/models/space.py +++ b/cloudisk/db/models/space.py @@ -1,5 +1,6 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Field, Session, SQLModel, select +from sqlmodel import Field, Session, SQLModel, select from cloudisk.db.models.base import ModelManager @@ -10,7 +11,8 @@ class SpaceModel(SQLModel, table=True): id: int = Field(primary_key=True) name: str = Field(unique=True) - protect: bool + used: bool = False + protect: bool = False class Space(ModelManager): @@ -44,10 +46,9 @@ def create(self, name: str, protect: bool) -> SpaceModel: When a space already exists. """ with Session(self.engine) as session: - space = self.model( - name=name, - protect=protect, - ) + is_used = self.scope.extras.get("space_id") is None + + space = self.model(name=name, protect=protect, used=is_used) session.add(space) @@ -60,6 +61,78 @@ def create(self, name: str, protect: bool) -> SpaceModel: return space + def remove(self, name: str) -> None: + """ + Remove a `SpaceModel` from the database. + + Parameters + ---------- + name: str + Name of the space. + """ + with Session(self.engine) as session: + statement = select(self.model).where(self.model.name == name) + results = session.exec(statement) + space = results.one() + + session.delete(space) + session.commit() + + def use(self, name: str) -> SpaceModel: + """ + Set `space.used` to `True`. + + Parameters + ---------- + name: str + Name of the space. + + Returns + ------- + SpaceModel + The modified instance. + """ + with Session(self.engine) as session: + statement = select(self.model).where(self.model.used == 1) + results = session.exec(statement) + space = results.one_or_none() + + if space: + if space.name == name: + return space + + space.used = False + + session.add(space) + session.commit() + + statement = select(self.model).where(self.model.name == name) + results = session.exec(statement) + space = results.one_or_none() + + space.used = True + + session.add(space) + session.commit() + + return space + + def used(self) -> SpaceModel: + """ + Get the space where `space.used` is `True`. + + Returns + ------- + SpaceModel + The created instance. + """ + with Session(self.engine) as session: + statement = select(self.model).where(self.model.used == 1) + results = session.exec(statement) + space = results.one_or_none() + + return space + def list(self) -> list[str]: """ List all instances of `SpaceModel`. diff --git a/cloudisk/fs/commands.py b/cloudisk/fs/commands.py index 32a17d1..3d0c123 100644 --- a/cloudisk/fs/commands.py +++ b/cloudisk/fs/commands.py @@ -3,11 +3,10 @@ import typer -from cloudisk.db.models import Space from cloudisk.fs.utils import ask_remove_dir, ask_remove_path from cloudisk.logger import get_logger from cloudisk.tools.settings import Settings -from cloudisk.vars import CLOUDISK_DB_FILE, CLOUDISK_ROOT +from cloudisk.vars import CLOUDISK_DB_FILE, CLOUDISK_ROOT, CLOUDISK_SETTINGS_FILE logger = get_logger("cloudisk.fs") @@ -109,22 +108,44 @@ def unlink_path(path: Path) -> None: def create_space(name: str, protect: bool) -> None: + from cloudisk.db.models import Space + if not CLOUDISK_ROOT.exists(): init_cloudisk_root() space_path = CLOUDISK_ROOT / name - if space_path.exists() and not ask_remove_dir(space_path): - return + if space_path.exists(): + if not ask_remove_dir(space_path): + return + + Space().remove(name=name) space_path.mkdir() - Settings.build_module(space_path / "settings.py") + Settings.build_module(CLOUDISK_ROOT / CLOUDISK_SETTINGS_FILE) Space().create(name=name, protect=protect) logger.info(f"Created the '{name}' space") +def use_space(name: str) -> None: + from cloudisk.db.models import Space + + if not CLOUDISK_ROOT.exists(): + init_cloudisk_root() + + space_path = CLOUDISK_ROOT / name + + if not space_path.exists(): + logger.error(f"Space '{name}' doesn't exist.") + return + + Space().use(name=name) + + logger.info(f"Using space '{name}'") + + # TODO maybe a space is in the database but not found in ROOT def list_spaces() -> None: spaces = Space().list() diff --git a/cloudisk/vars.py b/cloudisk/vars.py index 2e1ff4a..cef1cf7 100644 --- a/cloudisk/vars.py +++ b/cloudisk/vars.py @@ -5,5 +5,7 @@ CLOUDISK_DB_FILE = ".cloudisk.db" CLOUDISK_DB_PATH = CLOUDISK_ROOT / CLOUDISK_DB_FILE +CLOUDISK_SETTINGS_FILE = "settings.py" + MB_1 = 1024 * 1024 MB_100 = MB_1 * 100 From b9431f182f5ede321cc1d70a443f2e5f422cab8d Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Sat, 7 Mar 2026 23:13:49 +0100 Subject: [PATCH 05/12] Create BaseModel and inherit from all models --- cloudisk/db/models/base.py | 11 +++++++++-- cloudisk/db/models/group.py | 11 ++++------- cloudisk/db/models/metadata.py | 9 ++++----- cloudisk/db/models/user.py | 10 ++++------ 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/cloudisk/db/models/base.py b/cloudisk/db/models/base.py index 9257960..babd9e4 100644 --- a/cloudisk/db/models/base.py +++ b/cloudisk/db/models/base.py @@ -1,11 +1,16 @@ from abc import ABC, abstractmethod from sqlalchemy import inspect -from sqlmodel import SQLModel +from sqlmodel import Field, SQLModel from cloudisk.globals import context +class BaseModel(SQLModel): + id: int = Field(primary_key=True) + space_id: int = Field(default=None, index=True) + + class AbstractManager(ABC): @abstractmethod def table_exists(self) -> bool: @@ -37,9 +42,11 @@ def table_exists(self) -> bool: class ModelManager(AbstractManager): def __init__(self): # noqa: D107 - self.engine = context.engine + self.scope = getattr(self.__class__, "scope", context.root) self.model = getattr(self.__class__, "model", None) + self.engine = getattr(self.scope, "engine", None) + SQLModel.metadata.create_all(self.engine) # Abstract diff --git a/cloudisk/db/models/group.py b/cloudisk/db/models/group.py index e2c618c..4ce5926 100644 --- a/cloudisk/db/models/group.py +++ b/cloudisk/db/models/group.py @@ -1,20 +1,17 @@ from typing import TYPE_CHECKING, Optional -from sqlmodel import Field, Relationship, Session, SQLModel, select +from sqlmodel import Field, Relationship, Session, select from cloudisk.db.links import UserGroupLink - -from .base import ModelManager +from cloudisk.db.models.base import BaseModel, ModelManager if TYPE_CHECKING: from cloudisk.db.models.user import UserModel -class GroupModel(SQLModel, table=True): +class GroupModel(BaseModel, table=True): __tablename__ = "group" - id: int = Field(primary_key=True) - name: str = Field(unique=True) users: list["UserModel"] = Relationship( @@ -56,7 +53,7 @@ def create(self, name: str) -> GroupModel: if session.exec(query).one_or_none(): raise Group.AlreadyExists(f"Group '{name}' already exists") - group = self.model(name=name) + group = self.model(space_id=self.scope.extras.get("space_id"), name=name) session.add(group) session.commit() diff --git a/cloudisk/db/models/metadata.py b/cloudisk/db/models/metadata.py index ff3eef3..549ff0d 100644 --- a/cloudisk/db/models/metadata.py +++ b/cloudisk/db/models/metadata.py @@ -3,17 +3,15 @@ from typing import Optional from sqlalchemy.exc import IntegrityError -from sqlmodel import Field, Session, SQLModel, select +from sqlmodel import Field, Session, select -from cloudisk.db.models.base import ModelManager +from cloudisk.db.models.base import BaseModel, ModelManager from cloudisk.fs.utils import get_mime_type -class MetadataModel(SQLModel, table=True): +class MetadataModel(BaseModel, table=True): __tablename__ = "metadata" - id: int = Field(primary_key=True) - path: str = Field(unique=True) size: int = 0 content_type: Optional[str] = None @@ -95,6 +93,7 @@ def create(self, path: Path) -> MetadataModel: now = datetime.now() metadata = self.model( + space_id=self.scope.extras.get("space_id"), path=path_str, size=path.stat().st_size, content_type=get_mime_type(path), diff --git a/cloudisk/db/models/user.py b/cloudisk/db/models/user.py index e422c7d..a830547 100644 --- a/cloudisk/db/models/user.py +++ b/cloudisk/db/models/user.py @@ -3,24 +3,21 @@ from email.message import EmailMessage from typing import TYPE_CHECKING, Optional -from sqlmodel import Field, Relationship, Session, SQLModel, select +from sqlmodel import Field, Relationship, Session, select from cloudisk.db.links import UserGroupLink +from cloudisk.db.models.base import BaseModel, ModelManager from cloudisk.globals import settings -from .base import ModelManager - if TYPE_CHECKING: from cloudisk.db.models.group import GroupModel # TODO save encrypted passwords -class UserModel(SQLModel, table=True): +class UserModel(BaseModel, table=True): __tablename__ = "user" - id: int = Field(primary_key=True) - username: str = Field(unique=True) email: str = Field(unique=True) password: str @@ -93,6 +90,7 @@ def register(self, username: str, email: str, password: str) -> UserModel: raise User.EmailExists(f"Email '{email}' already exists") user = self.model( + space_id=self.scope.extras.get("space_id"), username=username, email=email, password=password, From 0c8fefef27d72750a3d6e4e07094766778e7ebad Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Sun, 8 Mar 2026 19:54:08 +0100 Subject: [PATCH 06/12] Use the space root instead of CLOUDISK_ROOT in the files endpoints --- cloudisk/fs/utils.py | 32 +++++++++++++++++++++++++++++--- cloudisk/http/dependencies.py | 8 ++++---- cloudisk/http/routers/files.py | 19 +++++++++++-------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/cloudisk/fs/utils.py b/cloudisk/fs/utils.py index 7cfa3cd..df30470 100644 --- a/cloudisk/fs/utils.py +++ b/cloudisk/fs/utils.py @@ -6,12 +6,38 @@ from filetype import guess_mime +from cloudisk.globals import context from cloudisk.logger import get_logger from cloudisk.vars import CLOUDISK_ROOT, MB_1 logger = get_logger("cloudisk.fs") +def get_space_root() -> Path: + """ + Return the absolute path of the used space root. + + Returns + ------- + Path + The root directory of the space. + """ + return path_resolve(CLOUDISK_ROOT / context.root.extras.get("space_name")) + + +def build_space_path(path: Path) -> Path: + """ + Return the absolute path inside the used space root. + + Returns + ------- + Path + The root directory of the space. + """ + space_path = get_space_root() / path + return path_resolve(space_path) + + def get_mime_type(path: Path) -> str | None: """ Get mime type of the given file. @@ -53,7 +79,7 @@ def is_subpath(child_path: Path, parent_path: Optional[Path] = None) -> bool: True if child path is subpath of parent path. False otherwise or if both paths are the same. """ - parent_path = path_resolve(parent_path or CLOUDISK_ROOT) + parent_path = path_resolve(parent_path or get_space_root()) child_path = path_resolve(child_path) if child_path == parent_path: @@ -79,7 +105,7 @@ def is_parent_path(child_path: Path, parent_path: Optional[Path] = None) -> bool True if parent path is a superpath of child path. False otherwise or if both paths are the same. """ - parent_path = path_resolve(parent_path or CLOUDISK_ROOT) + parent_path = path_resolve(parent_path or get_space_root()) child_path = path_resolve(child_path) if child_path == parent_path: @@ -100,7 +126,7 @@ def path_resolve(path: Path) -> Path: Returns ------- - pathlib.Path + Path Resolved path or same one if path or any of its parents are symlinks. """ if path.is_symlink(): diff --git a/cloudisk/http/dependencies.py b/cloudisk/http/dependencies.py index af02783..b403700 100644 --- a/cloudisk/http/dependencies.py +++ b/cloudisk/http/dependencies.py @@ -2,8 +2,8 @@ from fastapi import HTTPException, Query -from cloudisk.fs.utils import is_parent_path, path_resolve -from cloudisk.vars import CLOUDISK_DB_PATH, CLOUDISK_ROOT +from cloudisk.fs.utils import build_space_path, get_space_root, is_parent_path +from cloudisk.vars import CLOUDISK_DB_PATH EXCLUDED_PATHS = [CLOUDISK_DB_PATH] @@ -27,9 +27,9 @@ async def validate_path(path: Path = Query("")) -> Path: HTTPException If given path is backwards from the root directory or in excluded paths. """ - storage_path = path_resolve(CLOUDISK_ROOT / path) + storage_path = build_space_path(path) - if storage_path == CLOUDISK_ROOT: + if storage_path == get_space_root(): return path if is_parent_path(storage_path) or storage_path in EXCLUDED_PATHS: diff --git a/cloudisk/http/routers/files.py b/cloudisk/http/routers/files.py index 2630c31..7dd8996 100644 --- a/cloudisk/http/routers/files.py +++ b/cloudisk/http/routers/files.py @@ -8,14 +8,15 @@ from cloudisk.db.models import Metadata from cloudisk.fs.utils import ( attachment_content_disposition, + build_space_path, get_mime_type, + get_space_root, is_subpath, iter_file_chunks, - path_resolve, ) from cloudisk.http.dependencies import validate_path from cloudisk.logger import logger -from cloudisk.vars import CLOUDISK_DB_FILE, CLOUDISK_ROOT, MB_100 +from cloudisk.vars import CLOUDISK_DB_FILE, MB_100 EXCLUDED_FILES = [CLOUDISK_DB_FILE] @@ -67,7 +68,7 @@ async def _list_files(path: Path) -> JSONResponse: file_names = [file.name for file in files] - return JSONResponse({"files": file_names, "isRoot": path == CLOUDISK_ROOT}) + return JSONResponse({"files": file_names, "isRoot": path == get_space_root()}) async def _download_files(path: Path) -> FileResponse | StreamingResponse: @@ -127,7 +128,7 @@ async def get_files(path: Path = Depends(validate_path)): path : Path Path that is being accessed. """ - storage_path = path_resolve(CLOUDISK_ROOT / path) + storage_path = build_space_path(path) endpoint = _download_files if storage_path.is_file() else _list_files response = await endpoint(storage_path) @@ -151,9 +152,11 @@ async def upload_file(files: list[UploadFile] = File(...)): files : list[UploadFile] List of files that are being uploaded. """ + space_path = get_space_root() + for file in files: filename = Path(file.filename) - path = CLOUDISK_ROOT / filename + path = space_path / filename if not is_subpath(path): raise HTTPException(403, f"You are not allowed to create {path.as_posix()}") @@ -173,7 +176,7 @@ async def upload_file(files: list[UploadFile] = File(...)): logger.error(f"There was an error while uploading {path}: {e}") os.remove(path) - return await _list_files(CLOUDISK_ROOT) + return await _list_files(space_path) @router.delete( @@ -189,7 +192,7 @@ async def upload_file(files: list[UploadFile] = File(...)): async def delete_file( path: Path = Query( ..., - description=f"File or dir path to be deleted from {CLOUDISK_ROOT.as_posix()}", + description="File or dir path to be deleted from the selected space", ), ): """ @@ -213,7 +216,7 @@ async def delete_file( HTTPException - 500 If an error occurs deleting the file or directory. """ - storage_path = CLOUDISK_ROOT / path + storage_path = build_space_path(path) if not is_subpath(storage_path): raise HTTPException( From 03a6a90a611d02c17db2eec53eedc11a136daf39 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Sun, 8 Mar 2026 21:51:26 +0100 Subject: [PATCH 07/12] Adapt Scope and Settings to use the space values --- cloudisk/globals.py | 15 ++++++-------- cloudisk/tools/scope.py | 41 ++++++++++++++++++++++++++++++++++---- cloudisk/tools/settings.py | 28 +++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/cloudisk/globals.py b/cloudisk/globals.py index c470c81..2ea7c4e 100644 --- a/cloudisk/globals.py +++ b/cloudisk/globals.py @@ -5,17 +5,14 @@ from cloudisk.tools import Context, Scope from cloudisk.vars import CLOUDISK_DB_PATH -root_scope = Scope( - "root", - engine_path=CLOUDISK_DB_PATH, - settings_module="cloudisk.tools._settings.default", -) +root = Scope("root", engine_path=CLOUDISK_DB_PATH) -root_scope.settings.set_default( +root.update_space() +root.settings.set_default( STATIC_PATH=str(Path(__file__).parent.parent / "templates" / "js") ) -# TMP -settings = root_scope.settings +context = Context(scopes=[root]) -context = Context(scopes=[root_scope]) +# TMP +settings = root.settings diff --git a/cloudisk/tools/scope.py b/cloudisk/tools/scope.py index f44c27b..d090482 100644 --- a/cloudisk/tools/scope.py +++ b/cloudisk/tools/scope.py @@ -1,9 +1,11 @@ from pathlib import Path -from typing import Optional +from typing import Any, Optional -from sqlalchemy import create_engine +from sqlalchemy import inspect, text +from sqlmodel import Session from cloudisk.tools.settings import Settings +from cloudisk.vars import CLOUDISK_ROOT, CLOUDISK_SETTINGS_FILE class Scope: @@ -12,12 +14,17 @@ def __init__( # noqa: D107 name: str, *, engine_path: Optional[Path] = None, + settings_path: Optional[str] = None, settings_module: Optional[str] = None, + extras: Optional[dict[str, Any]] = None, ): self.name = name self.engine_path = engine_path + self.settings_path = settings_path self.settings_module = settings_module + self.extras = extras or {} + self._engine = None self._settings = None @@ -31,11 +38,14 @@ def engine(self): @property def settings(self): if self._settings is None: - self._settings = Settings(self.settings_module) + self._settings = Settings( + module=self.settings_module, + path=self.settings_path, + ) return self._settings - def set_engine(self, path: str | Path): + def set_engine(self, path: str | Path = None): if path is None: path = self.engine_path @@ -44,9 +54,32 @@ def set_engine(self, path: str | Path): self._engine = self._create_engine(path) + def update_space(self): + if not inspect(self.engine).has_table("space"): + return + + with Session(self.engine) as session: + # Query without using the Space model manager to avoid circular imports + result = session.exec(text("SELECT id, name FROM space WHERE space.used = 1")) + space = result.one_or_none() + + if not space: + return + + space_id, space_name = space + + space_path = CLOUDISK_ROOT / space_name + self.settings_path = space_path / CLOUDISK_SETTINGS_FILE + + # TODO refresh this value on runtime + self.extras["space_id"] = space_id + self.extras["space_name"] = space_name + def cleanup(self): self._engine.dispose() self._engine = None def _create_engine(self, path: str): + from sqlalchemy import create_engine + return create_engine(f"sqlite:///{path}") diff --git a/cloudisk/tools/settings.py b/cloudisk/tools/settings.py index 7fa5fa1..4992894 100644 --- a/cloudisk/tools/settings.py +++ b/cloudisk/tools/settings.py @@ -1,10 +1,11 @@ -import importlib import os import shutil from functools import cache from pathlib import Path from typing import Any +from cloudisk.vars import CLOUDISK_ROOT, CLOUDISK_SETTINGS_FILE + from . import _settings @@ -15,8 +16,29 @@ class Error(Exception): class BadKeyFormat(Error): # noqa: N818 """Raised when the key's format or type is not correct.""" - def __init__(self, module: str = None): # noqa: D107 - self.module = importlib.import_module(module) if module else None + class Incompatible(Error): # noqa: N818 + """Raised when the configuration keys are not compatible.""" + + def __init__(self, module: str = None, path: Path | str = None): # noqa: D107 + if module and path: + raise Settings.Incompatible( + "Module and path can't be provided at the same time." + ) + + if not module and not path: + self.module = None + self.path = CLOUDISK_ROOT / CLOUDISK_SETTINGS_FILE + + if module: + import importlib + + self.module = importlib.import_module(module) if module else None + + elif path: + import importlib.util + + spec = importlib.util.spec_from_file_location("settings", path) + self.module = importlib.util.module_from_spec(spec) def __getattr__(self, name: str): # noqa: D105 return self.get(name) From f8c22b6c964988d9a57ca668955c74fa2e9cf639 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Mon, 9 Mar 2026 23:55:24 +0100 Subject: [PATCH 08/12] Prepare conftest --- cloudisk/db/models/user.py | 2 +- tests/conftest.py | 14 ++++++++------ tests/http/routers/test_files.py | 2 -- tests/http/test_dependencies.py | 1 - tests/tools/test_context.py | 27 ++++++++++++++++----------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/cloudisk/db/models/user.py b/cloudisk/db/models/user.py index a830547..93c6b35 100644 --- a/cloudisk/db/models/user.py +++ b/cloudisk/db/models/user.py @@ -184,7 +184,7 @@ def _send_verify_email(self, email: str): msg["To"] = email if not (email_from := settings.EMAIL_FROM): - raise Exception("Please define CLOUDISK_EMAIL_FROM") + raise Exception("Please define EMAIL_FROM") msg["From"] = email_from diff --git a/tests/conftest.py b/tests/conftest.py index 7d8bd5b..a2a36ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -from cloudisk.globals import context, settings +from cloudisk.tools import Context, Scope from cloudisk.vars import CLOUDISK_DB_FILE TEST_USER = "test_user" @@ -11,13 +11,15 @@ @pytest.fixture(autouse=True) def fake_db(tmp_path, monkeypatch): tmp_db = tmp_path / CLOUDISK_DB_FILE + monkeypatch.setattr("cloudisk.http.dependencies.CLOUDISK_DB_PATH", tmp_db) - monkeypatch.setattr("cloudisk.tools.context.CLOUDISK_DB_PATH", tmp_db) - context.reset() - settings.clear_cache() + monkeypatch.setattr("cloudisk.tools.scope.CLOUDISK_ROOT", tmp_path) - monkeypatch.setattr("cloudisk.http.dependencies.CLOUDISK_DB_PATH", tmp_db) + test_context = Context(scopes=[Scope("root", engine_path=tmp_db)]) + test_context.root.update_space() + + monkeypatch.setattr("cloudisk.globals.context", test_context) yield tmp_db - context.engine.dispose() + # del test_context diff --git a/tests/http/routers/test_files.py b/tests/http/routers/test_files.py index 2239a60..6bff8f3 100644 --- a/tests/http/routers/test_files.py +++ b/tests/http/routers/test_files.py @@ -35,8 +35,6 @@ def fake_root(tmp_path, monkeypatch) -> Path: dir1_file2.write_text("Test Dir1 2") monkeypatch.setattr("cloudisk.fs.utils.CLOUDISK_ROOT", tmp_path) - monkeypatch.setattr("cloudisk.http.dependencies.CLOUDISK_ROOT", tmp_path) - monkeypatch.setattr("cloudisk.http.routers.files.CLOUDISK_ROOT", tmp_path) file_list = [ file1, diff --git a/tests/http/test_dependencies.py b/tests/http/test_dependencies.py index 0369c86..269f34e 100644 --- a/tests/http/test_dependencies.py +++ b/tests/http/test_dependencies.py @@ -11,7 +11,6 @@ def fake_root(tmp_path, monkeypatch) -> Path: (tmp_path / "file1.txt").write_text("Test 1") monkeypatch.setattr("cloudisk.fs.utils.CLOUDISK_ROOT", tmp_path) - monkeypatch.setattr("cloudisk.http.dependencies.CLOUDISK_ROOT", tmp_path) return tmp_path diff --git a/tests/tools/test_context.py b/tests/tools/test_context.py index aa1456e..54a26e3 100644 --- a/tests/tools/test_context.py +++ b/tests/tools/test_context.py @@ -1,23 +1,28 @@ -from cloudisk.tools.context import Context +from cloudisk.tools.scope import Scope from cloudisk.vars import CLOUDISK_DB_FILE -def test__init__(): - context = Context() +def test__init__(tmp_path): + tmp_db = tmp_path / CLOUDISK_DB_FILE + + instance = Scope("test", engine_path=tmp_db) - assert context._engine is None + assert instance.engine_path == tmp_db + assert instance._engine is None -def test_engine(tmp_path, monkeypatch): - context = Context() +def test_engine(tmp_path): tmp_db = tmp_path / CLOUDISK_DB_FILE + instance = Scope("test", engine_path=tmp_db) - monkeypatch.setattr("cloudisk.tools.context.CLOUDISK_DB_PATH", tmp_db) + assert str(instance.engine.url) == f"sqlite:///{tmp_db}" + assert instance._engine is None - assert str(context.engine.url) == f"sqlite:///{tmp_db}" +def test_cleanup(tmp_path): + tmp_db = tmp_path / CLOUDISK_DB_FILE + instance = Scope("test", engine_path=tmp_db) -def test_reset(): - context = Context() + instance.cleanup() - assert context._engine is None + assert instance._engine is None From 6aeb668d4772378a5776e7adbdb0100188fc5dc8 Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Tue, 10 Mar 2026 23:58:08 +0100 Subject: [PATCH 09/12] Tests related to models and endpoints --- cloudisk/tools/scope.py | 3 +++ tests/conftest.py | 24 ++++++++++++++++++------ tests/db/models/test_base.py | 5 +++-- tests/db/models/test_group.py | 5 +++-- tests/db/models/test_metadata.py | 5 +++-- tests/db/models/test_space.py | 5 +++-- tests/db/models/test_user.py | 5 +++-- tests/http/routers/conftest.py | 6 ++++++ tests/http/routers/test_auth.py | 3 +++ 9 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 tests/http/routers/conftest.py diff --git a/cloudisk/tools/scope.py b/cloudisk/tools/scope.py index d090482..22cfba6 100644 --- a/cloudisk/tools/scope.py +++ b/cloudisk/tools/scope.py @@ -64,6 +64,9 @@ def update_space(self): space = result.one_or_none() if not space: + count = session.exec(text("COUNT(*) FROM space")) + space = count + return space_id, space_name = space diff --git a/tests/conftest.py b/tests/conftest.py index a2a36ea..d43ea4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,13 +13,25 @@ def fake_db(tmp_path, monkeypatch): tmp_db = tmp_path / CLOUDISK_DB_FILE monkeypatch.setattr("cloudisk.http.dependencies.CLOUDISK_DB_PATH", tmp_db) - monkeypatch.setattr("cloudisk.tools.scope.CLOUDISK_ROOT", tmp_path) + return tmp_db - test_context = Context(scopes=[Scope("root", engine_path=tmp_db)]) - test_context.root.update_space() + # del test_context - monkeypatch.setattr("cloudisk.globals.context", test_context) - yield tmp_db +@pytest.fixture(autouse=True) +def fake_context(tmp_path, monkeypatch, fake_db): + monkeypatch.setattr("cloudisk.vars.CLOUDISK_ROOT", tmp_path) - # del test_context + test_scope = Scope("root", engine_path=fake_db) + + test_scope.extras["space_id"] = 999 + test_scope.extras["space_name"] = "test_space" + + (tmp_path / test_scope.extras["space_name"]).mkdir() + + test_context = Context(scopes=[test_scope]) + + monkeypatch.setattr("cloudisk.globals.context", test_context) + monkeypatch.setattr("cloudisk.db.models.base.context", test_context) + + return test_context diff --git a/tests/db/models/test_base.py b/tests/db/models/test_base.py index 12b081d..8d882a9 100644 --- a/tests/db/models/test_base.py +++ b/tests/db/models/test_base.py @@ -3,10 +3,11 @@ from cloudisk.db.models.base import ModelManager -def test__init__(fake_db): +def test__init__(fake_context): manager = ModelManager() - assert str(manager.engine.url) == f"sqlite:///{fake_db}" + assert manager.engine.url == fake_context.root.engine.url + assert manager.scope == fake_context.root assert manager.model is None diff --git a/tests/db/models/test_group.py b/tests/db/models/test_group.py index e3089b5..8a68cb6 100644 --- a/tests/db/models/test_group.py +++ b/tests/db/models/test_group.py @@ -3,10 +3,11 @@ from cloudisk.db.models.group import Group, GroupModel -def test__init__(fake_db): +def test__init__(fake_context): manager = Group() - assert str(manager.engine.url) == f"sqlite:///{fake_db}" + assert manager.engine.url == fake_context.root.engine.url + assert manager.scope == fake_context.root assert manager.model == GroupModel diff --git a/tests/db/models/test_metadata.py b/tests/db/models/test_metadata.py index d4e0f76..dd52b6b 100644 --- a/tests/db/models/test_metadata.py +++ b/tests/db/models/test_metadata.py @@ -44,10 +44,11 @@ def fake_root(tmp_path, monkeypatch): return tmp_path -def test__init__(fake_db): +def test__init__(fake_context): manager = Metadata() - assert str(manager.engine.url) == f"sqlite:///{fake_db}" + assert manager.engine.url == fake_context.root.engine.url + assert manager.scope == fake_context.root assert manager.model == MetadataModel diff --git a/tests/db/models/test_space.py b/tests/db/models/test_space.py index fec26f4..f05e88b 100644 --- a/tests/db/models/test_space.py +++ b/tests/db/models/test_space.py @@ -3,10 +3,11 @@ from cloudisk.db.models.space import Space, SpaceModel -def test__init__(fake_db): +def test__init__(fake_context): manager = Space() - assert str(manager.engine.url) == f"sqlite:///{fake_db}" + assert manager.engine.url == fake_context.root.engine.url + assert manager.scope == fake_context.root assert manager.model == SpaceModel diff --git a/tests/db/models/test_user.py b/tests/db/models/test_user.py index a82e73c..cdfb826 100644 --- a/tests/db/models/test_user.py +++ b/tests/db/models/test_user.py @@ -16,10 +16,11 @@ def mock_send_verify_email(monkeypatch, request): ) -def test__init__(fake_db): +def test__init__(fake_context): manager = User() - assert str(manager.engine.url) == f"sqlite:///{fake_db}" + assert manager.engine.url == fake_context.root.engine.url + assert manager.scope == fake_context.root assert manager.model == UserModel diff --git a/tests/http/routers/conftest.py b/tests/http/routers/conftest.py new file mode 100644 index 0000000..d64019d --- /dev/null +++ b/tests/http/routers/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(autouse=True) +def setup(monkeypatch, fake_context): + monkeypatch.setattr("cloudisk.fs.utils.context", fake_context) diff --git a/tests/http/routers/test_auth.py b/tests/http/routers/test_auth.py index 57fe5b0..615470e 100644 --- a/tests/http/routers/test_auth.py +++ b/tests/http/routers/test_auth.py @@ -29,6 +29,7 @@ def test_register(): expected_user = { "id": 1, + "space_id": 999, "username": TEST_USER, "email": TEST_MAIL, "password": TEST_PASS, @@ -58,6 +59,7 @@ def test_verify(): expected_user = { "id": 1, + "space_id": 999, "username": TEST_USER, "email": TEST_MAIL, "password": TEST_PASS, @@ -93,6 +95,7 @@ def test_login(): expected_user = { "id": 1, + "space_id": 999, "username": TEST_USER, "email": TEST_MAIL, "password": TEST_PASS, From 6b3c2190bbedb6c04ced7a12a47c78a4d886649f Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Wed, 11 Mar 2026 01:44:32 +0100 Subject: [PATCH 10/12] Finish correcting all failed tests --- cloudisk/globals.py | 13 ++++---- cloudisk/http/server.py | 3 ++ cloudisk/tools/scope.py | 15 +++++---- tests/conftest.py | 1 + tests/fs/test_utils.py | 52 +++++++++++++++++--------------- tests/http/routers/test_files.py | 20 +++++++----- tests/http/test_dependencies.py | 9 ++++-- tests/tools/test_context.py | 9 +++++- 8 files changed, 73 insertions(+), 49 deletions(-) diff --git a/cloudisk/globals.py b/cloudisk/globals.py index 2ea7c4e..2362537 100644 --- a/cloudisk/globals.py +++ b/cloudisk/globals.py @@ -1,18 +1,17 @@ """Contains global variables that refer to the global state in runtime.""" +import os from pathlib import Path from cloudisk.tools import Context, Scope from cloudisk.vars import CLOUDISK_DB_PATH -root = Scope("root", engine_path=CLOUDISK_DB_PATH) - -root.update_space() -root.settings.set_default( - STATIC_PATH=str(Path(__file__).parent.parent / "templates" / "js") +os.environ.setdefault( + "CLOUDISK_STATIC_PATH", + str(Path(__file__).parent.parent / "templates" / "js"), ) -context = Context(scopes=[root]) +context = Context(scopes=[Scope("root", engine_path=CLOUDISK_DB_PATH)]) # TMP -settings = root.settings +settings = context.root.settings diff --git a/cloudisk/http/server.py b/cloudisk/http/server.py index ba6a5fb..a44cd31 100644 --- a/cloudisk/http/server.py +++ b/cloudisk/http/server.py @@ -3,6 +3,7 @@ import uvicorn import cloudisk +from cloudisk.globals import context def run(host: str = "0.0.0.0", port: int = 8000) -> None: @@ -16,6 +17,8 @@ def run(host: str = "0.0.0.0", port: int = 8000) -> None: port : int Port to run server in. By default, 8000. """ + context.root.update_space() + uvicorn.run( app="cloudisk.http.config:app", host=host, diff --git a/cloudisk/tools/scope.py b/cloudisk/tools/scope.py index 22cfba6..6433322 100644 --- a/cloudisk/tools/scope.py +++ b/cloudisk/tools/scope.py @@ -45,16 +45,16 @@ def settings(self): return self._settings - def set_engine(self, path: str | Path = None): + def set_engine(self, path: Optional[str | Path] = None): if path is None: path = self.engine_path - if isinstance(path, Path): - path = str(path) - self._engine = self._create_engine(path) def update_space(self): + if not self.engine: + return + if not inspect(self.engine).has_table("space"): return @@ -82,7 +82,10 @@ def cleanup(self): self._engine.dispose() self._engine = None - def _create_engine(self, path: str): + def _create_engine(self, path: Path): from sqlalchemy import create_engine - return create_engine(f"sqlite:///{path}") + if not path.parent.exists(): + path.parent.mkdir() + + return create_engine(f"sqlite:///{str(path)}") diff --git a/tests/conftest.py b/tests/conftest.py index d43ea4e..96d6ed0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ def fake_context(tmp_path, monkeypatch, fake_db): test_context = Context(scopes=[test_scope]) monkeypatch.setattr("cloudisk.globals.context", test_context) + monkeypatch.setattr("cloudisk.fs.utils.context", test_context) monkeypatch.setattr("cloudisk.db.models.base.context", test_context) return test_context diff --git a/tests/fs/test_utils.py b/tests/fs/test_utils.py index e3643d3..a70d899 100644 --- a/tests/fs/test_utils.py +++ b/tests/fs/test_utils.py @@ -56,14 +56,14 @@ def mock_shutil_rmtree(): yield mocked_rmtree -def test_get_mime_type_from_file_returns_mime_type(mock_jpg_file: MagicMock): +def test_get_mime_type_from_file_returns_mime_type(mock_jpg_file): path, expected = mock_jpg_file result = get_mime_type(path) assert result == expected -def test_get_mime_type_from_path_returns_None(tmp_path: Path): +def test_get_mime_type_from_path_returns_None(tmp_path): expected = None result = get_mime_type(tmp_path) @@ -80,41 +80,41 @@ def test_get_mime_type_guess_mime_fails_returns_None(): assert result is None -def test_is_subpath_is_True(tmp_path: Path): +def test_is_subpath_is_True(tmp_path): previous_path = (tmp_path / "..").resolve() assert is_subpath(tmp_path, previous_path) is True -def test_is_subpath_is_False(tmp_path: Path): +def test_is_subpath_is_False(tmp_path): previous_path = (tmp_path / "..").resolve() assert is_subpath(previous_path, tmp_path) is False -def test_is_subpath_is_same(tmp_path: Path): +def test_is_subpath_is_same(tmp_path): assert is_subpath(tmp_path, tmp_path) is False -def test_is_parent_path_is_True(tmp_path: Path): +def test_is_parent_path_is_True(tmp_path): previous_path = (tmp_path / "..").resolve() assert is_parent_path(previous_path, tmp_path) is True -def test_is_parent_path_is_False(tmp_path: Path): +def test_is_parent_path_is_False(tmp_path): previous_path = (tmp_path / "..").resolve() assert is_parent_path(tmp_path, previous_path) is False -def test_is_parent_path_is_same(tmp_path: Path): +def test_is_parent_path_is_same(tmp_path): assert is_parent_path(tmp_path, tmp_path) is False -def test_path_resolve_is_symlink(tmp_path: Path): +def test_path_resolve_is_symlink(tmp_path): symlink = tmp_path / "symlink" os.symlink(tmp_path, symlink) assert path_resolve(symlink) == symlink -def test_path_resolve_parent_is_symlink(tmp_path: Path): +def test_path_resolve_parent_is_symlink(tmp_path): symlink = tmp_path / "symlink" os.symlink(tmp_path, symlink) tmp_file = symlink / "tmp_file" @@ -122,8 +122,8 @@ def test_path_resolve_parent_is_symlink(tmp_path: Path): def test_remove_file_logs_error_and_then_returns_False( - mock_jpg_file: MagicMock, - mock_logger_error: MagicMock, + mock_jpg_file, + mock_logger_error, ): path, _ = mock_jpg_file @@ -134,7 +134,7 @@ def test_remove_file_logs_error_and_then_returns_False( mock_logger_error.assert_called_once() -def test_remove_file_returns_True(mock_jpg_file: MagicMock, mock_path_unlink: MagicMock): +def test_remove_file_returns_True(mock_jpg_file, mock_path_unlink): path, _ = mock_jpg_file responses = iter(("y")) @@ -145,8 +145,8 @@ def test_remove_file_returns_True(mock_jpg_file: MagicMock, mock_path_unlink: Ma def test_remove_dir_logs_error_and_then_returns_False( - tmp_path: Path, - mock_logger_error: MagicMock, + tmp_path, + mock_logger_error, ): responses = iter(("no", "n")) with patch("builtins.input", side_effect=lambda _: next(responses)): @@ -155,17 +155,19 @@ def test_remove_dir_logs_error_and_then_returns_False( mock_logger_error.assert_called_once() -def test_remove_dir_when_empty_returns_True(tmp_path: Path, mock_path_rmdir: MagicMock): +def test_remove_dir_when_empty_returns_True(tmp_path, fake_context, mock_path_rmdir): + space_name = fake_context.root.extras["space_name"] responses = iter(("y")) + with patch("builtins.input", side_effect=lambda _: next(responses)): - result = ask_remove_dir(tmp_path) + result = ask_remove_dir(tmp_path / space_name) assert result is True mock_path_rmdir.assert_called_once() def test_remove_dir_when_not_empty_returns_True( - tmp_path: Path, - mock_shutil_rmtree: MagicMock, + tmp_path, + mock_shutil_rmtree, ): (tmp_path / "tmp_file").touch() @@ -176,7 +178,7 @@ def test_remove_dir_when_not_empty_returns_True( mock_shutil_rmtree.assert_called_once() -def test_remove_path_when_file(mock_jpg_file: MagicMock, mock_path_unlink: MagicMock): +def test_remove_path_when_file(mock_jpg_file, mock_path_unlink): responses = iter(("y")) with patch("builtins.input", side_effect=lambda _: next(responses)): path, _ = mock_jpg_file @@ -184,14 +186,16 @@ def test_remove_path_when_file(mock_jpg_file: MagicMock, mock_path_unlink: Magic mock_path_unlink.assert_called_once() -def test_remove_path_when_path(tmp_path: Path, mock_path_rmdir: MagicMock): +def test_remove_path_when_path(tmp_path, fake_context, mock_path_rmdir): + space_name = fake_context.root.extras["space_name"] responses = iter(("y")) + with patch("builtins.input", side_effect=lambda _: next(responses)): - assert ask_remove_path(tmp_path) is True + assert ask_remove_path(tmp_path / space_name) is True mock_path_rmdir.assert_called_once() -def test_remove_path_raises_exception(tmp_path: Path): +def test_remove_path_raises_exception(tmp_path): fake_path = tmp_path / "tmp_socket.sock" exception_text = ( f"{fake_path.as_posix()} already exists and is not a file or a directory. " @@ -216,7 +220,7 @@ def test_attachment_content_disposition_returns_formatted_filename(): assert attachment_content_disposition(file_name) == expected -def test_iter_file_chunks_yield_chunk(tmp_path: Path): +def test_iter_file_chunks_yield_chunk(tmp_path): fake_data = b"123456789" chunk_size = 4 diff --git a/tests/http/routers/test_files.py b/tests/http/routers/test_files.py index 6bff8f3..5ef1d80 100644 --- a/tests/http/routers/test_files.py +++ b/tests/http/routers/test_files.py @@ -15,17 +15,20 @@ @pytest.fixture(autouse=True) -def fake_root(tmp_path, monkeypatch) -> Path: - file1 = tmp_path / "file1.txt" +def fake_root(tmp_path, monkeypatch, fake_context) -> Path: + space_name = fake_context.root.extras["space_name"] + space_path = tmp_path / space_name + + file1 = space_path / "file1.txt" file1.write_text("Test 1") - file2 = tmp_path / "file2.fake" + file2 = space_path / "file2.fake" file2.write_text("Test 2") - link1 = tmp_path / "link1.txt" - link1.symlink_to(tmp_path / "file1.txt") + link1 = space_path / "link1.txt" + link1.symlink_to(space_path / "file1.txt") - dir1 = tmp_path / "dir1" + dir1 = space_path / "dir1" dir1.mkdir() dir1_file1 = dir1 / "dir1_file1.txt" @@ -50,7 +53,7 @@ def fake_root(tmp_path, monkeypatch) -> Path: for file in file_list: fake_metadata.create(file) - return tmp_path + return space_path @pytest.fixture @@ -336,7 +339,8 @@ def test_delete_file_err_403_subpath(fake_root): assert not path.exists() content = response.json() - assert content["detail"] == f"You are not allowed to delete {path.as_posix()}" + path = path.resolve().as_posix() + assert content["detail"] == f"You are not allowed to delete {path}" def test_delete_file_err_404_path_doesnt_exist(fake_root): diff --git a/tests/http/test_dependencies.py b/tests/http/test_dependencies.py index 269f34e..4e60c65 100644 --- a/tests/http/test_dependencies.py +++ b/tests/http/test_dependencies.py @@ -7,12 +7,15 @@ @pytest.fixture(autouse=True) -def fake_root(tmp_path, monkeypatch) -> Path: - (tmp_path / "file1.txt").write_text("Test 1") +def fake_root(tmp_path, monkeypatch, fake_context) -> Path: + space_name = fake_context.root.extras["space_name"] + space_path = tmp_path / space_name + + (space_path / "file1.txt").write_text("Test 1") monkeypatch.setattr("cloudisk.fs.utils.CLOUDISK_ROOT", tmp_path) - return tmp_path + return space_path @pytest.mark.asyncio diff --git a/tests/tools/test_context.py b/tests/tools/test_context.py index 54a26e3..d4ecba8 100644 --- a/tests/tools/test_context.py +++ b/tests/tools/test_context.py @@ -7,8 +7,14 @@ def test__init__(tmp_path): instance = Scope("test", engine_path=tmp_db) + assert instance.name == "test" assert instance.engine_path == tmp_db + assert instance.settings_path is None + assert instance.settings_module is None + + assert instance.extras == {} assert instance._engine is None + assert instance._settings is None def test_engine(tmp_path): @@ -16,13 +22,14 @@ def test_engine(tmp_path): instance = Scope("test", engine_path=tmp_db) assert str(instance.engine.url) == f"sqlite:///{tmp_db}" - assert instance._engine is None + assert instance._engine is not None def test_cleanup(tmp_path): tmp_db = tmp_path / CLOUDISK_DB_FILE instance = Scope("test", engine_path=tmp_db) + instance.set_engine() instance.cleanup() assert instance._engine is None From de806a25e6c8f3f9306e2d35e3c3fc78a0c0374c Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Thu, 12 Mar 2026 01:23:34 +0100 Subject: [PATCH 11/12] Tests for all changes related to space management --- cloudisk/db/models/space.py | 7 ++- cloudisk/db/models/user.py | 2 +- cloudisk/fs/commands.py | 6 +- cloudisk/fs/utils.py | 2 +- cloudisk/http/config.py | 11 +++- cloudisk/http/server.py | 3 - cloudisk/tools/context.py | 4 +- cloudisk/tools/scope.py | 29 ++++++---- cloudisk/tools/settings.py | 17 +++--- pyproject.toml | 3 + tests/conftest.py | 3 +- tests/db/models/test_space.py | 56 +++++++++++++++++-- tests/db/models/test_user.py | 8 ++- tests/fs/test_commands.py | 32 +++++++++++ tests/fs/test_utils.py | 17 ++++++ tests/tools/test_context.py | 54 ++++++++++++------ tests/tools/test_scope.py | 100 ++++++++++++++++++++++++++++++++++ tests/tools/test_settings.py | 28 +++++++++- 18 files changed, 321 insertions(+), 61 deletions(-) create mode 100644 tests/tools/test_scope.py diff --git a/cloudisk/db/models/space.py b/cloudisk/db/models/space.py index 1ce8dfc..673d2ef 100644 --- a/cloudisk/db/models/space.py +++ b/cloudisk/db/models/space.py @@ -24,7 +24,7 @@ class Error(Exception): class AlreadyExists(Error): # noqa: N818 """Raised when the space already exists.""" - def create(self, name: str, protect: bool) -> SpaceModel: + def create(self, name: str, protect: bool = False) -> SpaceModel: """ Create a `SpaceModel` instance. @@ -32,7 +32,7 @@ def create(self, name: str, protect: bool) -> SpaceModel: ---------- name: str Name of the space. - protect: bool + protect: bool = False Marks the space as protected with user login. Returns @@ -114,10 +114,11 @@ def use(self, name: str) -> SpaceModel: session.add(space) session.commit() + session.refresh(space) return space - def used(self) -> SpaceModel: + def get_used(self) -> SpaceModel: """ Get the space where `space.used` is `True`. diff --git a/cloudisk/db/models/user.py b/cloudisk/db/models/user.py index 93c6b35..9def774 100644 --- a/cloudisk/db/models/user.py +++ b/cloudisk/db/models/user.py @@ -184,7 +184,7 @@ def _send_verify_email(self, email: str): msg["To"] = email if not (email_from := settings.EMAIL_FROM): - raise Exception("Please define EMAIL_FROM") + raise User.Error("Please define EMAIL_FROM") msg["From"] = email_from diff --git a/cloudisk/fs/commands.py b/cloudisk/fs/commands.py index 3d0c123..64fe1f0 100644 --- a/cloudisk/fs/commands.py +++ b/cloudisk/fs/commands.py @@ -107,7 +107,7 @@ def unlink_path(path: Path) -> None: logger.info(f"Unlinked '{path}'") -def create_space(name: str, protect: bool) -> None: +def create_space(name: str, protect: bool = False) -> None: from cloudisk.db.models import Space if not CLOUDISK_ROOT.exists(): @@ -121,7 +121,7 @@ def create_space(name: str, protect: bool) -> None: Space().remove(name=name) - space_path.mkdir() + space_path.mkdir(exist_ok=True) Settings.build_module(CLOUDISK_ROOT / CLOUDISK_SETTINGS_FILE) Space().create(name=name, protect=protect) @@ -133,7 +133,7 @@ def use_space(name: str) -> None: from cloudisk.db.models import Space if not CLOUDISK_ROOT.exists(): - init_cloudisk_root() + init_cloudisk_root() # pragma: no cover space_path = CLOUDISK_ROOT / name diff --git a/cloudisk/fs/utils.py b/cloudisk/fs/utils.py index df30470..ea93276 100644 --- a/cloudisk/fs/utils.py +++ b/cloudisk/fs/utils.py @@ -22,7 +22,7 @@ def get_space_root() -> Path: Path The root directory of the space. """ - return path_resolve(CLOUDISK_ROOT / context.root.extras.get("space_name")) + return path_resolve(CLOUDISK_ROOT / context.root.extras["space_name"]) def build_space_path(path: Path) -> Path: diff --git a/cloudisk/http/config.py b/cloudisk/http/config.py index 8e05b05..8346f6e 100644 --- a/cloudisk/http/config.py +++ b/cloudisk/http/config.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from typing import Any, Mapping from fastapi import FastAPI, HTTPException, Request @@ -5,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from cloudisk.db.models import Metadata, User +from cloudisk.globals import context from cloudisk.http.routers import auth, files, root from cloudisk.http.vars import CLOUDISK_STATIC from cloudisk.logger import get_logger @@ -21,8 +23,15 @@ # Global API logger logger = get_logger("cloudisk.api") + +@asynccontextmanager +async def lifespan(app: FastAPI): # pragma: no cover + context.root.update_space() + yield + + # Initialize app -app = FastAPI(**API_CONFIG) +app = FastAPI(lifespan=lifespan, **API_CONFIG) # Include routers in app app.include_router(auth.router) diff --git a/cloudisk/http/server.py b/cloudisk/http/server.py index a44cd31..ba6a5fb 100644 --- a/cloudisk/http/server.py +++ b/cloudisk/http/server.py @@ -3,7 +3,6 @@ import uvicorn import cloudisk -from cloudisk.globals import context def run(host: str = "0.0.0.0", port: int = 8000) -> None: @@ -17,8 +16,6 @@ def run(host: str = "0.0.0.0", port: int = 8000) -> None: port : int Port to run server in. By default, 8000. """ - context.root.update_space() - uvicorn.run( app="cloudisk.http.config:app", host=host, diff --git a/cloudisk/tools/context.py b/cloudisk/tools/context.py index 8e70697..05f7119 100644 --- a/cloudisk/tools/context.py +++ b/cloudisk/tools/context.py @@ -4,7 +4,7 @@ class Context: - def __init__(self, scopes: Optional[dict[str, Scope] | list[Scope]]): # noqa: D107 + def __init__(self, scopes: Optional[dict[str, Scope] | list[Scope]] = None): # noqa: D107 if scopes is None: scopes = {} @@ -20,4 +20,4 @@ def add_scope(self, scope: Scope): self.scopes[scope.name] = scope def drop_scope(self, name: str): - del self.scope[name] + self.scopes.pop(name, None) diff --git a/cloudisk/tools/scope.py b/cloudisk/tools/scope.py index 6433322..fb3072c 100644 --- a/cloudisk/tools/scope.py +++ b/cloudisk/tools/scope.py @@ -9,6 +9,12 @@ class Scope: + class Error(Exception): + """Raised when the problem doesn't fit any of the other exceptions.""" + + class NoSpace(Error): # noqa: N818 + """Raised when there is no space associated to this scope.""" + def __init__( # noqa: D107 self, name: str, @@ -46,17 +52,16 @@ def settings(self): return self._settings def set_engine(self, path: Optional[str | Path] = None): - if path is None: - path = self.engine_path - self._engine = self._create_engine(path) def update_space(self): if not self.engine: - return + return # pragma: no cover if not inspect(self.engine).has_table("space"): - return + raise Scope.NoSpace( + "Create a space before running cloudisk. More info: 'cloudisk create -h'" + ) with Session(self.engine) as session: # Query without using the Space model manager to avoid circular imports @@ -64,10 +69,9 @@ def update_space(self): space = result.one_or_none() if not space: - count = session.exec(text("COUNT(*) FROM space")) - space = count - - return + raise Scope.NoSpace( + "There is no space defined as used. More info: 'cloudisk use -h'" + ) space_id, space_name = space @@ -82,10 +86,13 @@ def cleanup(self): self._engine.dispose() self._engine = None - def _create_engine(self, path: Path): + def _create_engine(self, path: Optional[Path] = None): from sqlalchemy import create_engine + if path is None: + path = self.engine_path + if not path.parent.exists(): - path.parent.mkdir() + path.parent.mkdir() # pragma: no cover return create_engine(f"sqlite:///{str(path)}") diff --git a/cloudisk/tools/settings.py b/cloudisk/tools/settings.py index 4992894..6fa534d 100644 --- a/cloudisk/tools/settings.py +++ b/cloudisk/tools/settings.py @@ -26,19 +26,22 @@ def __init__(self, module: str = None, path: Path | str = None): # noqa: D107 ) if not module and not path: - self.module = None - self.path = CLOUDISK_ROOT / CLOUDISK_SETTINGS_FILE + module = None + path = CLOUDISK_ROOT / CLOUDISK_SETTINGS_FILE if module: import importlib - self.module = importlib.import_module(module) if module else None + self.module = importlib.import_module(module) + self.path = Path(self.module.__file__) + return - elif path: - import importlib.util + import importlib.util - spec = importlib.util.spec_from_file_location("settings", path) - self.module = importlib.util.module_from_spec(spec) + spec = importlib.util.spec_from_file_location("settings", path) + + self.module = importlib.util.module_from_spec(spec) + self.path = Path(path) def __getattr__(self, name: str): # noqa: D105 return self.get(name) diff --git a/pyproject.toml b/pyproject.toml index 2219868..17c30e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,9 @@ omit = [ "__main__.py", "static/*", "tests/*", + "cloudisk/logger.py", + "cloudisk/globals.py", + "cloudisk/**/vars.py", "cloudisk/cli/*", "cloudisk/db/links/*", "cloudisk/tools/_settings/*", diff --git a/tests/conftest.py b/tests/conftest.py index 96d6ed0..3d6cf40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,12 +15,11 @@ def fake_db(tmp_path, monkeypatch): return tmp_db - # del test_context - @pytest.fixture(autouse=True) def fake_context(tmp_path, monkeypatch, fake_db): monkeypatch.setattr("cloudisk.vars.CLOUDISK_ROOT", tmp_path) + monkeypatch.setattr("cloudisk.tools.settings.CLOUDISK_ROOT", tmp_path) test_scope = Scope("root", engine_path=fake_db) diff --git a/tests/db/models/test_space.py b/tests/db/models/test_space.py index f05e88b..c53552c 100644 --- a/tests/db/models/test_space.py +++ b/tests/db/models/test_space.py @@ -14,13 +14,11 @@ def test__init__(fake_context): def test_create_ok(): manager = Space() - name = "test" - protect = True + space = manager.create(name="test", protect=True) - space = manager.create(name=name, protect=protect) - - assert space.name == name + assert space.name == "test" assert space.protect is True + assert space.used is False def test_create_raises_AlreadyExists(): @@ -32,6 +30,54 @@ def test_create_raises_AlreadyExists(): manager.create(name="test", protect=True) +def test_use(): + manager = Space() + + manager.create(name="test", protect=True) + space = manager.use(name="test") + + assert space.name == "test" + assert space.protect is True + assert space.used is True + + +def test_use_change_used(): + manager = Space() + + manager.create(name="test1", protect=True) + manager.create(name="test2", protect=True) + manager.use(name="test1") + space = manager.use(name="test2") + + assert space.name == "test2" + assert space.protect is True + assert space.used is True + + +def test_use_when_space_is_already_used(): + manager = Space() + + manager.create(name="test1", protect=True) + manager.use(name="test1") + space = manager.use(name="test1") + + assert space.name == "test1" + assert space.protect is True + assert space.used is True + + +def test_get_used(): + manager = Space() + + manager.create(name="test", protect=True) + manager.use(name="test") + space = manager.get_used() + + assert space.name == "test" + assert space.protect is True + assert space.used is True + + def test_list(): manager = Space() diff --git a/tests/db/models/test_user.py b/tests/db/models/test_user.py index cdfb826..3eb3540 100644 --- a/tests/db/models/test_user.py +++ b/tests/db/models/test_user.py @@ -124,9 +124,11 @@ def test_one_or_none_returns_None(): @pytest.mark.no_mock def test_send_verify_email_ok(monkeypatch): mock_smtp = MagicMock() + mock_settings = MagicMock() + mock_settings.EMAIL_FROM = TEST_MAIL monkeypatch.setattr("cloudisk.db.models.user.smtplib.SMTP", mock_smtp) - monkeypatch.setenv("CLOUDISK_EMAIL_FROM", TEST_MAIL) + monkeypatch.setattr("cloudisk.db.models.user.settings", mock_settings) User()._send_verify_email(email=TEST_MAIL) @@ -134,6 +136,6 @@ def test_send_verify_email_ok(monkeypatch): @pytest.mark.no_mock -def test_send_verify_email_raises_Exception(): - with pytest.raises(Exception): +def test_send_verify_email_raises_Error(): + with pytest.raises(User.Error): User()._send_verify_email(email=TEST_MAIL) diff --git a/tests/fs/test_commands.py b/tests/fs/test_commands.py index f4eb42a..7e2d6f8 100644 --- a/tests/fs/test_commands.py +++ b/tests/fs/test_commands.py @@ -14,6 +14,7 @@ link_path, list_spaces, unlink_path, + use_space, ) @@ -233,6 +234,37 @@ def test_create_space_ask_remove_dir_is_False(fake_root): assert space_path.exists() +def test_create_space_ask_remove_dir_is_True(fake_root): + space_name = "test" + space_path = fake_root / space_name + + create_space(name=space_name, protect=True) + + with patch.object(commands, "ask_remove_dir", return_value=True): + create_space(name=space_name, protect=True) + + assert space_path.exists() + + +def test_use_space(fake_root): + space_name = "test" + space_path = fake_root / space_name + + create_space(space_name) + use_space(space_name) + + assert space_path.exists() + + +def test_use_space_that_doesnt_exist(fake_root): + space_name = "test" + space_path = fake_root / space_name + + use_space(space_name) + + assert not space_path.exists() + + def test_list_spaces_full(fake_root): Space().create(name="test", protect=False) diff --git a/tests/fs/test_utils.py b/tests/fs/test_utils.py index a70d899..ca897a5 100644 --- a/tests/fs/test_utils.py +++ b/tests/fs/test_utils.py @@ -11,12 +11,15 @@ ask_remove_file, ask_remove_path, attachment_content_disposition, + build_space_path, get_mime_type, + get_space_root, is_parent_path, is_subpath, iter_file_chunks, path_resolve, ) +from cloudisk.vars import CLOUDISK_ROOT @pytest.fixture @@ -56,6 +59,20 @@ def mock_shutil_rmtree(): yield mocked_rmtree +def test_get_space_root(fake_context): + result = get_space_root() + expected = CLOUDISK_ROOT / fake_context.root.extras["space_name"] + + assert result == expected + + +def test_build_space_path(fake_context): + result = build_space_path("test_path") + expected = CLOUDISK_ROOT / fake_context.root.extras["space_name"] / "test_path" + + assert result == expected + + def test_get_mime_type_from_file_returns_mime_type(mock_jpg_file): path, expected = mock_jpg_file result = get_mime_type(path) diff --git a/tests/tools/test_context.py b/tests/tools/test_context.py index d4ecba8..dba67a2 100644 --- a/tests/tools/test_context.py +++ b/tests/tools/test_context.py @@ -1,3 +1,4 @@ +from cloudisk.tools.context import Context from cloudisk.tools.scope import Scope from cloudisk.vars import CLOUDISK_DB_FILE @@ -5,31 +6,50 @@ def test__init__(tmp_path): tmp_db = tmp_path / CLOUDISK_DB_FILE - instance = Scope("test", engine_path=tmp_db) + instance = Context(scopes=[Scope("test", engine_path=tmp_db)]) - assert instance.name == "test" - assert instance.engine_path == tmp_db - assert instance.settings_path is None - assert instance.settings_module is None + assert len(instance.scopes) == 1 + assert "test" in instance.scopes - assert instance.extras == {} - assert instance._engine is None - assert instance._settings is None +def test__init__no_scope(): + instance = Context() -def test_engine(tmp_path): + assert len(instance.scopes) == 0 + assert instance.scopes == {} + + +def test_add_scope(tmp_path): tmp_db = tmp_path / CLOUDISK_DB_FILE - instance = Scope("test", engine_path=tmp_db) - assert str(instance.engine.url) == f"sqlite:///{tmp_db}" - assert instance._engine is not None + instance = Context() + instance.add_scope(Scope("test", engine_path=tmp_db)) + + assert len(instance.scopes) == 1 + assert "test" in instance.scopes -def test_cleanup(tmp_path): +def test_get_scope(tmp_path): tmp_db = tmp_path / CLOUDISK_DB_FILE - instance = Scope("test", engine_path=tmp_db) - instance.set_engine() - instance.cleanup() + instance = Context(scopes=[Scope("test", engine_path=tmp_db)]) + + assert instance.test == instance.scopes["test"] + + +def test_drop_scope(tmp_path): + tmp_db = tmp_path / CLOUDISK_DB_FILE + + instance = Context(scopes=[Scope("test", engine_path=tmp_db)]) + instance.drop_scope("test") + + assert len(instance.scopes) == 0 + assert instance.scopes == {} + + +def test_drop_scope_when_scopes_is_empty(): + instance = Context() + instance.drop_scope("test") - assert instance._engine is None + assert len(instance.scopes) == 0 + assert instance.scopes == {} diff --git a/tests/tools/test_scope.py b/tests/tools/test_scope.py new file mode 100644 index 0000000..748958d --- /dev/null +++ b/tests/tools/test_scope.py @@ -0,0 +1,100 @@ +import pytest + +from cloudisk.db.models.space import Space +from cloudisk.tools.scope import Scope +from cloudisk.vars import CLOUDISK_SETTINGS_FILE + + +def test__init__(fake_db): + instance = Scope("test", engine_path=fake_db) + + assert instance.name == "test" + assert instance.engine_path == fake_db + assert instance.settings_path is None + assert instance.settings_module is None + + assert instance.extras == {} + assert instance._engine is None + assert instance._settings is None + + +def test_engine(fake_db): + instance = Scope("test", engine_path=fake_db) + + assert str(instance.engine.url) == f"sqlite:///{fake_db}" + assert instance._engine is not None + + +def test_settings(tmp_path, fake_db): + fake_settings_path = tmp_path / CLOUDISK_SETTINGS_FILE + fake_settings_path.touch() + + instance = Scope( + "test", + engine_path=fake_db, + settings_path=fake_settings_path, + ) + + assert instance.settings.path == fake_settings_path + assert instance.settings.module is not None + + +def test_set_engine(fake_db): + instance = Scope("test", engine_path=fake_db) + + instance.set_engine() + + assert instance._engine is not None + + +def test_set_engine_uses_previously_created(fake_db): + instance = Scope("test", engine_path=fake_db) + + instance.set_engine(path=fake_db) + assert instance._engine is not None + + +def test_update_space(fake_db): + manager = Space() + manager.create(name="foo") + manager.use(name="foo") + + instance = Scope("test", engine_path=fake_db) + instance.update_space() + + assert instance.extras["space_id"] == 1 + assert instance.extras["space_name"] == "foo" + + +def test_update_space_no_space_table(fake_db): + instance = Scope("test", engine_path=fake_db) + + with pytest.raises(Scope.NoSpace): + instance.update_space() + + +def test_update_space_no_space_used(fake_db): + manager = Space() + manager.create(name="foo") + + instance = Scope("test", engine_path=fake_db) + + with pytest.raises(Scope.NoSpace): + instance.update_space() + + +def test_cleanup(fake_db): + instance = Scope("test", engine_path=fake_db) + + instance.set_engine() + instance.cleanup() + + assert instance._engine is None + + +def test_create_engine(fake_db): + instance = Scope("test", engine_path=fake_db) + + engine = instance._create_engine() + + assert str(engine.url) == f"sqlite:///{fake_db}" diff --git a/tests/tools/test_settings.py b/tests/tools/test_settings.py index 4c287f2..f8e1f79 100644 --- a/tests/tools/test_settings.py +++ b/tests/tools/test_settings.py @@ -13,15 +13,26 @@ def test__init__env(): instance = Settings() - assert instance.module is None + assert instance.module is not None def test__init__module(): - instance = Settings(CONFTEST_MODULE) + instance = Settings(module=CONFTEST_MODULE) + + assert isinstance(instance.module, ModuleType) + + +def test__init__path(): + instance = Settings(path=__file__) assert isinstance(instance.module, ModuleType) +def test__init__raises_Incompatible(): + with pytest.raises(Settings.Incompatible): + Settings(module=CONFTEST_MODULE, path=__file__) + + def test_get_env(): instance = Settings() @@ -50,6 +61,7 @@ def test_get_module(): def test_set_default_env(): instance = Settings() + instance.module = None instance.set_default(FOO="test", BAR=CLOUDISK_ROOT, TEST_MAIL="another@mail.com") @@ -68,6 +80,18 @@ def test_set_default_module(): assert instance.TEST_MAIL == TEST_MAIL +def test_set_default_ellipsis(): + instance = Settings(CONFTEST_MODULE) + instance.module.FOO = ... + instance.module.BAR = ... + + instance.set_default(FOO="test", BAR=CLOUDISK_ROOT, TEST_MAIL="another@mail.com") + + assert instance.FOO == instance.module.FOO == "test" + assert instance.BAR == instance.module.BAR == CLOUDISK_ROOT + assert instance.TEST_MAIL == TEST_MAIL + + def test_clear_cache(): instance = Settings() From 770ef9fc10f4037eb14057ec7d0d823c3780e25d Mon Sep 17 00:00:00 2001 From: Cristian Marcos Martin Date: Thu, 12 Mar 2026 22:22:38 +0100 Subject: [PATCH 12/12] Small fixes after rebasing with the 'list' command --- cloudisk/db/models/space.py | 6 ++++-- cloudisk/fs/commands.py | 22 +++++++++++++--------- tests/db/models/test_space.py | 35 +++++++++++++++++++++++++++++++++++ tests/fs/test_commands.py | 8 ++++++-- tests/tools/test_scope.py | 9 ++++++++- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/cloudisk/db/models/space.py b/cloudisk/db/models/space.py index 673d2ef..20a9cfa 100644 --- a/cloudisk/db/models/space.py +++ b/cloudisk/db/models/space.py @@ -1,6 +1,5 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Field, Session, SQLModel, select -from sqlmodel import Field, Session, SQLModel, select from cloudisk.db.models.base import ModelManager @@ -46,8 +45,11 @@ def create(self, name: str, protect: bool = False) -> SpaceModel: When a space already exists. """ with Session(self.engine) as session: - is_used = self.scope.extras.get("space_id") is None + statement = select(self.model).where(self.model.used) + results = session.exec(statement) + used_space = results.one_or_none() + is_used = not used_space space = self.model(name=name, protect=protect, used=is_used) session.add(space) diff --git a/cloudisk/fs/commands.py b/cloudisk/fs/commands.py index 64fe1f0..dcaff0f 100644 --- a/cloudisk/fs/commands.py +++ b/cloudisk/fs/commands.py @@ -148,25 +148,29 @@ def use_space(name: str) -> None: # TODO maybe a space is in the database but not found in ROOT def list_spaces() -> None: + from cloudisk.db.models import Space + spaces = Space().list() + used = Space().get_used() if spaces: typer.echo("Tracked spaces:") for space in spaces: - typer.echo(f"- {space}") + if space == used.name: + typer.echo(f"+ {space} (used)") + else: + typer.echo(f"- {space}") root = os.listdir(CLOUDISK_ROOT) - root = [x for x in root if x != CLOUDISK_DB_FILE] + root = [x for x in root if x not in [CLOUDISK_DB_FILE, CLOUDISK_SETTINGS_FILE]] - if len(root): - untracked = list(filter(lambda x: x not in spaces, root)) + untracked = list(filter(lambda x: x not in spaces, root)) - message = "Untracked spaces:" - if spaces: - message = "\n" + message + message = "Untracked spaces:" + if spaces: + message = "\n" + message + if untracked: typer.echo(message) for space in untracked: typer.echo(f"- {space}") - - return diff --git a/tests/db/models/test_space.py b/tests/db/models/test_space.py index c53552c..008f60b 100644 --- a/tests/db/models/test_space.py +++ b/tests/db/models/test_space.py @@ -1,4 +1,5 @@ import pytest +from sqlmodel import Session from cloudisk.db.models.space import Space, SpaceModel @@ -18,6 +19,22 @@ def test_create_ok(): assert space.name == "test" assert space.protect is True + assert space.used is True + + +def test_create_second_space(): + manager = Space() + + space = manager.create(name="foo", protect=True) + + assert space.name == "foo" + assert space.protect is True + assert space.used is True + + space = manager.create(name="bar", protect=True) + + assert space.name == "bar" + assert space.protect is True assert space.used is False @@ -66,6 +83,24 @@ def test_use_when_space_is_already_used(): assert space.used is True +def test_use_when_no_space_is_used(): + manager = Space() + + space = manager.create(name="test1", protect=True) + + with Session(manager.engine) as session: + space.used = False + + session.add(space) + session.commit() + + space = manager.use(name="test1") + + assert space.name == "test1" + assert space.protect is True + assert space.used is True + + def test_get_used(): manager = Space() diff --git a/tests/fs/test_commands.py b/tests/fs/test_commands.py index 7e2d6f8..93288f2 100644 --- a/tests/fs/test_commands.py +++ b/tests/fs/test_commands.py @@ -266,7 +266,11 @@ def test_use_space_that_doesnt_exist(fake_root): def test_list_spaces_full(fake_root): - Space().create(name="test", protect=False) + manager = Space() + + manager.create(name="foo", protect=False) + manager.create(name="bar", protect=False) + manager.create(name="baz", protect=False) untracked = fake_root / "untracked" untracked.mkdir() @@ -274,7 +278,7 @@ def test_list_spaces_full(fake_root): list_spaces() -def test_list_spaces_only_tracked(fake_root): +def test_list_spaces_only_tracked(): Space().create(name="test", protect=False) list_spaces() diff --git a/tests/tools/test_scope.py b/tests/tools/test_scope.py index 748958d..9afd137 100644 --- a/tests/tools/test_scope.py +++ b/tests/tools/test_scope.py @@ -1,4 +1,5 @@ import pytest +from sqlmodel import Session from cloudisk.db.models.space import Space from cloudisk.tools.scope import Scope @@ -75,7 +76,13 @@ def test_update_space_no_space_table(fake_db): def test_update_space_no_space_used(fake_db): manager = Space() - manager.create(name="foo") + space = manager.create(name="foo") + + with Session(manager.engine) as session: + space.used = False + + session.add(space) + session.commit() instance = Scope("test", engine_path=fake_db)