update dependencies
and more
This commit is contained in:
parent
727f1b51ee
commit
69a1aa2471
31
README.md
31
README.md
@ -16,6 +16,37 @@ alembic upgrade head
|
||||
# Rollback the migration
|
||||
alembic downgrade head
|
||||
```
|
||||
|
||||
## Setup tests
|
||||
|
||||
```sh
|
||||
nix build .#postgresql-devel
|
||||
podman load < result
|
||||
podman run -p 54320:5432 --name database -dt postgresql:latest
|
||||
nix build .#redis-devel
|
||||
podman load < result
|
||||
podman run -p 63790:63790 --name cache -dt redis:latest
|
||||
nix develop
|
||||
pdm install --dev
|
||||
eval $(pdm venv activate)
|
||||
pytest
|
||||
```
|
||||
|
||||
## Side notes
|
||||
|
||||
```
|
||||
/var
|
||||
/lib
|
||||
/materia <-- data directory
|
||||
/repository <-- repository directory
|
||||
/rick <-- user name
|
||||
/default <--| default repository name
|
||||
... | possible features: external cloud drives?
|
||||
/first <-- first level directories counts as root because no parent
|
||||
/nested
|
||||
/hello.txt
|
||||
```
|
||||
|
||||
# License
|
||||
|
||||
**materia** is licensed under the [MIT License](LICENSE).
|
||||
|
880
flake.lock
880
flake.lock
File diff suppressed because it is too large
Load Diff
43
flake.nix
43
flake.nix
@ -51,7 +51,7 @@
|
||||
...
|
||||
}: {
|
||||
name = "materia-frontend";
|
||||
version = "0.0.1";
|
||||
version = "0.0.5";
|
||||
|
||||
imports = [
|
||||
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
|
||||
@ -94,20 +94,20 @@
|
||||
}: {
|
||||
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
|
||||
|
||||
pdm.lockfile = ./materia-web-client/pdm.lock;
|
||||
pdm.pyproject = ./materia-web-client/pyproject.toml;
|
||||
pdm.lockfile = ./workspaces/frontend/pdm.lock;
|
||||
pdm.pyproject = ./workspaces/frontend/pyproject.toml;
|
||||
|
||||
deps = _: {
|
||||
python = pkgs.python3;
|
||||
python = pkgs.python312;
|
||||
};
|
||||
|
||||
mkDerivation = {
|
||||
src = ./workspaces/frontend;
|
||||
buildInputs = [
|
||||
pkgs.python3.pkgs.pdm-backend
|
||||
pkgs.python312.pkgs.pdm-backend
|
||||
];
|
||||
configurePhase = ''
|
||||
cp -rv ${materia-frontend-nodejs}/dist ./src/materia-frontend/
|
||||
cp -rv ${materia-frontend-nodejs}/dist ./src/materia_frontend/
|
||||
'';
|
||||
};
|
||||
};
|
||||
@ -119,7 +119,10 @@
|
||||
};
|
||||
};
|
||||
|
||||
materia-server = dreamBuildPackage {
|
||||
materia = dreamBuildPackage {
|
||||
extraArgs = {
|
||||
inherit (self.packages.x86_64-linux) materia-frontend;
|
||||
};
|
||||
module = {
|
||||
config,
|
||||
lib,
|
||||
@ -129,20 +132,23 @@
|
||||
}: {
|
||||
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
|
||||
|
||||
pdm.lockfile = ./materia-server/pdm.lock;
|
||||
pdm.pyproject = ./materia-server/pyproject.toml;
|
||||
pdm.lockfile = ./pdm.lock;
|
||||
pdm.pyproject = ./pyproject.toml;
|
||||
|
||||
deps = _: {
|
||||
python = pkgs.python3;
|
||||
python = pkgs.python312;
|
||||
};
|
||||
|
||||
mkDerivation = {
|
||||
src = ./materia-server;
|
||||
src = ./.;
|
||||
buildInputs = [
|
||||
pkgs.python3.pkgs.pdm-backend
|
||||
pkgs.python312.pkgs.pdm-backend
|
||||
];
|
||||
nativeBuildInputs = [
|
||||
pkgs.python3.pkgs.wrapPython
|
||||
pkgs.python312.pkgs.wrapPython
|
||||
];
|
||||
propagatedBuildInputs = [
|
||||
materia-frontend
|
||||
];
|
||||
};
|
||||
};
|
||||
@ -151,7 +157,7 @@
|
||||
license = licenses.mit;
|
||||
maintainers = with bonLib.maintainers; [L-Nafaryus];
|
||||
broken = false;
|
||||
mainProgram = "materia-server";
|
||||
mainProgram = "materia";
|
||||
};
|
||||
};
|
||||
|
||||
@ -160,15 +166,8 @@
|
||||
redis-devel = bonfire.packages.x86_64-linux.redis;
|
||||
};
|
||||
|
||||
apps.x86_64-linux = {
|
||||
materia-server = {
|
||||
type = "app";
|
||||
program = "${self.packages.x86_64-linux.materia-server}/bin/materia-server";
|
||||
};
|
||||
};
|
||||
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [postgresql redis pdm nodejs];
|
||||
buildInputs = with pkgs; [postgresql redis pdm nodejs python312];
|
||||
# greenlet requires libstdc++
|
||||
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
|
||||
};
|
||||
|
@ -20,7 +20,6 @@ dependencies = [
|
||||
"sqids<1.0.0,>=0.4.1",
|
||||
"alembic<2.0.0,>=1.13.1",
|
||||
"authlib<2.0.0,>=1.3.0",
|
||||
"cryptography<43.0.0,>=42.0.7",
|
||||
"redis[hiredis]<6.0.0,>=5.0.4",
|
||||
"aiosmtplib<4.0.0,>=3.0.1",
|
||||
"emails<1.0,>=0.6",
|
||||
@ -31,9 +30,12 @@ dependencies = [
|
||||
"alembic-postgresql-enum<2.0.0,>=1.2.0",
|
||||
"gunicorn>=22.0.0",
|
||||
"uvicorn-worker>=0.2.0",
|
||||
"httpx>=0.27.0"
|
||||
"httpx>=0.27.0",
|
||||
"cryptography>=43.0.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"jinja2>=3.1.4",
|
||||
]
|
||||
requires-python = "<3.12,>=3.10"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@ -69,10 +71,9 @@ includes = ["src/materia"]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}"
|
||||
setup.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'create role materia login;' -c 'create database materia owner materia;'"
|
||||
teardown.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'drop database materia;' -c 'drop role materia;'"
|
||||
rev.cmd = "alembic revision {args:--autogenerate}"
|
||||
upgrade.cmd = "alembic upgrade {args:head}"
|
||||
downgrade.cmd = "alembic downgrade {args:base}"
|
||||
rev.cmd = "alembic revision {args:--autogenerate}"
|
||||
rm-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
|
||||
|
||||
|
||||
|
||||
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
|
||||
|
@ -20,6 +20,7 @@ from materia.models.repository import (
|
||||
Repository,
|
||||
RepositoryInfo,
|
||||
RepositoryContent,
|
||||
RepositoryError,
|
||||
)
|
||||
|
||||
from materia.models.directory import Directory, DirectoryLink, DirectoryInfo
|
||||
|
@ -1,2 +1,9 @@
|
||||
from materia.models.database.database import DatabaseError, DatabaseMigrationError, Database
|
||||
from materia.models.database.database import (
|
||||
DatabaseError,
|
||||
DatabaseMigrationError,
|
||||
Database,
|
||||
SessionMaker,
|
||||
SessionContext,
|
||||
ConnectionContext,
|
||||
)
|
||||
from materia.models.database.cache import Cache, CacheError
|
||||
|
@ -1,6 +1,6 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from typing import AsyncIterator, Self
|
||||
from typing import AsyncIterator, Self, TypeAlias
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, PostgresDsn
|
||||
@ -17,6 +17,8 @@ from alembic.config import Config as AlembicConfig
|
||||
from alembic.operations import Operations
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script.base import ScriptDirectory
|
||||
import alembic_postgresql_enum
|
||||
from fastapi import HTTPException
|
||||
|
||||
from materia.config import Config
|
||||
from materia.models.base import Base
|
||||
@ -32,16 +34,21 @@ class DatabaseMigrationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
SessionContext: TypeAlias = AsyncIterator[AsyncSession]
|
||||
SessionMaker: TypeAlias = async_sessionmaker[AsyncSession]
|
||||
ConnectionContext: TypeAlias = AsyncIterator[AsyncConnection]
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(
|
||||
self,
|
||||
url: PostgresDsn,
|
||||
engine: AsyncEngine,
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
sessionmaker: SessionMaker,
|
||||
):
|
||||
self.url: PostgresDsn = url
|
||||
self.engine: AsyncEngine = engine
|
||||
self.sessionmaker: async_sessionmaker[AsyncSession] = sessionmaker
|
||||
self.sessionmaker: SessionMaker = sessionmaker
|
||||
|
||||
@staticmethod
|
||||
async def new(
|
||||
@ -81,7 +88,7 @@ class Database:
|
||||
await self.engine.dispose()
|
||||
|
||||
@asynccontextmanager
|
||||
async def connection(self) -> AsyncIterator[AsyncConnection]:
|
||||
async def connection(self) -> ConnectionContext:
|
||||
async with self.engine.connect() as connection:
|
||||
try:
|
||||
yield connection
|
||||
@ -90,7 +97,7 @@ class Database:
|
||||
raise DatabaseError(f"{e}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
async def session(self) -> SessionContext:
|
||||
session = self.sessionmaker()
|
||||
|
||||
try:
|
||||
@ -98,6 +105,9 @@ class Database:
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise DatabaseError(f"{e}")
|
||||
except HTTPException:
|
||||
# if the initial exception reaches HTTPException, then everything is handled fine (should be)
|
||||
await session.rollback()
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
from time import time
|
||||
from typing import List, Optional, Self
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey
|
||||
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||
@ -9,6 +10,11 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from materia.models.base import Base
|
||||
from materia.models import database
|
||||
from materia.models.database import SessionContext
|
||||
|
||||
|
||||
class DirectoryError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Directory(Base):
|
||||
@ -24,7 +30,6 @@ class Directory(Base):
|
||||
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||
name: Mapped[str]
|
||||
path: Mapped[str] = mapped_column(nullable=True)
|
||||
is_public: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
repository: Mapped["Repository"] = relationship(back_populates="directories")
|
||||
@ -35,32 +40,198 @@ class Directory(Base):
|
||||
files: Mapped[List["File"]] = relationship(back_populates="parent")
|
||||
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
|
||||
|
||||
@staticmethod
|
||||
async def by_path(
|
||||
repository_id: int, path: Path | None, name: str, db: database.Database
|
||||
) -> Self | None:
|
||||
async with db.session() as session:
|
||||
query_path = (
|
||||
Directory.path == str(path)
|
||||
if isinstance(path, Path)
|
||||
else Directory.path.is_(None)
|
||||
async def new(
|
||||
self,
|
||||
session: SessionContext,
|
||||
path: Optional[Path] = None,
|
||||
with_parents: bool = False,
|
||||
) -> Optional[Self]:
|
||||
session.add(self)
|
||||
await session.flush()
|
||||
await session.refresh(self, attribute_names=["repository", "parent"])
|
||||
|
||||
repository_path: Path = await self.repository.path(session)
|
||||
current_path: Path = repository_path
|
||||
current_directory: Optional[Directory] = None
|
||||
|
||||
for part in path.parts:
|
||||
current_path /= part
|
||||
relative_path = current_path.relative_to(repository_path)
|
||||
|
||||
if current_path.exists() and current_path.is_dir():
|
||||
# Find record
|
||||
current_directory = await Directory.find(
|
||||
self.repository, self.parent, self.name, session
|
||||
)
|
||||
|
||||
if not current_directory:
|
||||
# TODO: recreate record
|
||||
raise DirectoryError(
|
||||
f"No directory was found in the records: {relative_path}"
|
||||
)
|
||||
|
||||
current_directory.updated = time()
|
||||
await session.flush()
|
||||
|
||||
continue
|
||||
|
||||
if not with_parents:
|
||||
raise DirectoryError(f"Directory not exists at /{relative_path}")
|
||||
|
||||
# Create an ancestor directory from scratch
|
||||
current_directory = await Directory(
|
||||
repository_id=self.repository.id,
|
||||
parent_id=current_directory.id if current_directory else None,
|
||||
name=part,
|
||||
).new(
|
||||
session,
|
||||
path=relative_path,
|
||||
with_parents=False,
|
||||
)
|
||||
return (
|
||||
await session.scalars(
|
||||
sa.select(Directory).where(
|
||||
sa.and_(
|
||||
Directory.repository_id == repository_id,
|
||||
Directory.name == name,
|
||||
query_path,
|
||||
)
|
||||
|
||||
try:
|
||||
current_path.mkdir()
|
||||
except OSError as e:
|
||||
raise DirectoryError(
|
||||
f"Failed to create directory at /{relative_path}:", *e.args
|
||||
)
|
||||
|
||||
# Create directory
|
||||
current_path /= self.name
|
||||
relative_path = current_path.relative_to(repository_path)
|
||||
|
||||
try:
|
||||
current_path.mkdir()
|
||||
except OSError as e:
|
||||
raise DirectoryError(
|
||||
f"Failed to create directory at /{relative_path}:", *e.args
|
||||
)
|
||||
|
||||
# Update information
|
||||
self.parent = current_directory
|
||||
|
||||
await session.commit()
|
||||
|
||||
return self
|
||||
|
||||
async def remove(self, session: SessionContext):
|
||||
session.add(self)
|
||||
|
||||
current_path: Path = self.repository.path(session) / self.path(session)
|
||||
|
||||
try:
|
||||
shutil.tmtree(str(current_path))
|
||||
except OSError as e:
|
||||
raise DirectoryError("Failed to remove directory:", *e.args)
|
||||
|
||||
await session.refresh(self, attribute_names=["parent"])
|
||||
current_directory: Directory = self.parent
|
||||
|
||||
while current_directory:
|
||||
current_directory.updated = time()
|
||||
session.add(current_directory)
|
||||
await session.refresh(self, attribute_names=["parent"])
|
||||
current_directory = current_directory.parent
|
||||
|
||||
await session.delete(self)
|
||||
await session.commit()
|
||||
|
||||
async def is_root(self) -> bool:
|
||||
return self.parent_id is None
|
||||
|
||||
@staticmethod
|
||||
async def find(
|
||||
repository: "Repository",
|
||||
directory: "Directory",
|
||||
name: str,
|
||||
session: SessionContext,
|
||||
) -> Optional[Self]:
|
||||
return (
|
||||
await session.scalars(
|
||||
sa.select(Directory).where(
|
||||
sa.and_(
|
||||
Directory.repository_id == repository.id,
|
||||
Directory.name == name,
|
||||
Directory.parent_id == directory.parent_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
)
|
||||
).first()
|
||||
|
||||
async def find_nested(self, session: SessionContext):
|
||||
pass
|
||||
|
||||
async def find_by_descend(
|
||||
self, path: Path | str, db: database.Database, need_create: bool = False
|
||||
) -> Optional[Self]:
|
||||
"""Find a nested directory from current"""
|
||||
repository_id = self.repository_id
|
||||
path = Path(path)
|
||||
current_directory = self
|
||||
|
||||
async def remove(self, db: database.Database):
|
||||
async with db.session() as session:
|
||||
await session.delete(self)
|
||||
await session.commit()
|
||||
for part in path.parts:
|
||||
directory = (
|
||||
await session.scalars(
|
||||
sa.select(Directory).where(
|
||||
sa.and_(
|
||||
Directory.repository_id == repository_id,
|
||||
Directory.name == part,
|
||||
Directory.parent_id == current_directory.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if directory is None:
|
||||
if not need_create:
|
||||
return None
|
||||
|
||||
directory = Directory(
|
||||
repository_id=repository_id,
|
||||
parent_id=current_directory.id,
|
||||
name=part,
|
||||
)
|
||||
session.add(directory)
|
||||
await session.flush()
|
||||
|
||||
current_directory = directory
|
||||
|
||||
if need_create:
|
||||
await session.commit()
|
||||
|
||||
return current_directory
|
||||
|
||||
@staticmethod
|
||||
async def find_by_path(
|
||||
repository_id: int, path: Path | str, db: database.Database
|
||||
) -> Optional[Self]:
|
||||
"""Find a directory by given absolute path"""
|
||||
path = Path(path)
|
||||
assert path == Path(), "The path cannot be empty"
|
||||
|
||||
root = await Directory.find_by_descend(repository_id, path.parts[0], db)
|
||||
return root.descend(Path().joinpath(*path.parts[1:]), db)
|
||||
|
||||
async def path(self, session: SessionContext) -> Optional[Path]:
|
||||
"""Get relative path of the current directory"""
|
||||
parts = []
|
||||
current_directory = self
|
||||
|
||||
while True:
|
||||
parts.append(current_directory.name)
|
||||
session.add(current_directory)
|
||||
await session.refresh(current_directory, attribute_names=["parent"])
|
||||
|
||||
if current_directory.parent is None:
|
||||
break
|
||||
|
||||
current_directory = current_directory.parent
|
||||
|
||||
return Path().joinpath(*reversed(parts))
|
||||
|
||||
async def info(self) -> "DirectoryInfo":
|
||||
return DirectoryInfo.model_validate(self)
|
||||
|
||||
|
||||
class DirectoryLink(Base):
|
||||
|
@ -1,36 +0,0 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 86dd738cbd40
|
||||
Revises: 939b37d98be0
|
||||
Create Date: 2024-07-05 16:42:31.645410
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '86dd738cbd40'
|
||||
down_revision: Union[str, None] = '939b37d98be0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint('file_parent_id_fkey', 'file', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'file', 'directory', ['parent_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_constraint('repository_user_id_fkey', 'repository', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'repository', 'user', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'repository', type_='foreignkey')
|
||||
op.create_foreign_key('repository_user_id_fkey', 'repository', 'user', ['user_id'], ['id'])
|
||||
op.drop_constraint(None, 'file', type_='foreignkey')
|
||||
op.create_foreign_key('file_parent_id_fkey', 'file', 'directory', ['parent_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
@ -1,8 +1,8 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 939b37d98be0
|
||||
Revision ID: bf2ef6c7ab70
|
||||
Revises:
|
||||
Create Date: 2024-06-24 15:39:38.380581
|
||||
Create Date: 2024-08-02 18:37:01.697075
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '939b37d98be0'
|
||||
revision: str = 'bf2ef6c7ab70'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
@ -65,7 +65,7 @@ def upgrade() -> None:
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('capacity', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('directory',
|
||||
@ -75,7 +75,6 @@ def upgrade() -> None:
|
||||
sa.Column('created', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated', sa.BigInteger(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('path', sa.String(), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
|
||||
@ -110,7 +109,7 @@ def upgrade() -> None:
|
||||
sa.Column('path', sa.String(), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=False),
|
||||
sa.Column('size', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
@ -1,6 +1,8 @@
|
||||
from time import time
|
||||
from typing import List, Self, Optional
|
||||
from uuid import UUID, uuid4
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey
|
||||
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||
@ -10,6 +12,12 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from materia.models.base import Base
|
||||
from materia.models import database
|
||||
from materia.models.database import SessionContext
|
||||
from materia.config import Config
|
||||
|
||||
|
||||
class RepositoryError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Repository(Base):
|
||||
@ -23,6 +31,57 @@ class Repository(Base):
|
||||
directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
|
||||
files: Mapped[List["File"]] = relationship(back_populates="repository")
|
||||
|
||||
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
|
||||
session.add(self)
|
||||
await session.flush()
|
||||
repository_path = await self.path(session, config)
|
||||
|
||||
try:
|
||||
repository_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise RepositoryError(
|
||||
f"Failed to create repository at /{repository_path.relative_to(config.application.working_directory)}:",
|
||||
*e.args,
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
|
||||
return self
|
||||
|
||||
async def path(self, session: SessionContext, config: Config) -> Path:
|
||||
session.add(self)
|
||||
await session.refresh(self, attribute_names=["user"])
|
||||
|
||||
repository_path = config.application.working_directory.joinpath(
|
||||
"repository", self.user.lower_name, "default"
|
||||
)
|
||||
|
||||
return repository_path
|
||||
|
||||
async def remove(self, session: SessionContext, config: Config):
|
||||
session.add(self)
|
||||
await session.refresh(self, attribute_names=["directories", "files"])
|
||||
|
||||
for directory in self.directories:
|
||||
if directory.is_root():
|
||||
await directory.remove(session)
|
||||
|
||||
for file in self.files:
|
||||
await file.remove(session)
|
||||
|
||||
repository_path = await self.path(session, config)
|
||||
|
||||
try:
|
||||
shutil.rmtree(str(repository_path))
|
||||
except OSError as e:
|
||||
raise RepositoryError(
|
||||
f"Failed to remove repository at /{repository_path.relative_to(config.application.working_directory)}:",
|
||||
*e.args,
|
||||
)
|
||||
|
||||
await session.delete(self)
|
||||
await session.flush()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
k: getattr(self, k)
|
||||
@ -30,33 +89,20 @@ class Repository(Base):
|
||||
if isinstance(v, InstrumentedAttribute)
|
||||
}
|
||||
|
||||
async def create(self, db: database.Database):
|
||||
async with db.session() as session:
|
||||
session.add(self)
|
||||
await session.commit()
|
||||
|
||||
async def update(self, db: database.Database):
|
||||
async with db.session() as session:
|
||||
await session.execute(
|
||||
sa.update(Repository)
|
||||
.where(Repository.id == self.id)
|
||||
.values(self.to_dict())
|
||||
)
|
||||
await session.commit()
|
||||
async def update(self, session: SessionContext):
|
||||
await session.execute(
|
||||
sa.update(Repository).values(self.to_dict()).where(Repository.id == self.id)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
@staticmethod
|
||||
async def by_user_id(user_id: UUID, db: database.Database) -> Self | None:
|
||||
async with db.session() as session:
|
||||
return (
|
||||
await session.scalars(
|
||||
sa.select(Repository).where(Repository.user_id == user_id)
|
||||
)
|
||||
).first()
|
||||
async def from_user(user: "User", session: SessionContext) -> Optional[Self]:
|
||||
session.add(user)
|
||||
await session.refresh(user, attribute_names=["repository"])
|
||||
return user.repository
|
||||
|
||||
async def remove(self, db: database.Database):
|
||||
async with db.session() as session:
|
||||
await session.delete(self)
|
||||
await session.commit()
|
||||
async def info(self) -> "RepositoryInfo":
|
||||
return RepositoryInfo.model_validate(self)
|
||||
|
||||
|
||||
class RepositoryInfo(BaseModel):
|
||||
|
@ -1,5 +1,5 @@
|
||||
from uuid import UUID, uuid4
|
||||
from typing import Optional
|
||||
from typing import Optional, Self
|
||||
import time
|
||||
import re
|
||||
|
||||
@ -9,15 +9,22 @@ from sqlalchemy import BigInteger, Enum
|
||||
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||
import sqlalchemy as sa
|
||||
|
||||
from materia import security
|
||||
from materia.models.base import Base
|
||||
from materia.models.auth.source import LoginType
|
||||
from materia.models import database
|
||||
from materia.models.database import SessionContext
|
||||
from materia.config import Config
|
||||
from loguru import logger
|
||||
|
||||
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
|
||||
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
|
||||
|
||||
|
||||
class UserError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
@ -43,6 +50,23 @@ class User(Base):
|
||||
|
||||
repository: Mapped["Repository"] = relationship(back_populates="user")
|
||||
|
||||
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
|
||||
# Provide checks outer
|
||||
|
||||
session.add(self)
|
||||
await session.flush()
|
||||
return self
|
||||
|
||||
async def remove(self, session: SessionContext):
|
||||
session.add(self)
|
||||
await session.refresh(self, attribute_names=["repository"])
|
||||
|
||||
if self.repository:
|
||||
await self.repository.remove()
|
||||
|
||||
await session.delete(self)
|
||||
await session.flush()
|
||||
|
||||
def update_last_login(self):
|
||||
self.last_login = int(time.time())
|
||||
|
||||
@ -53,37 +77,74 @@ class User(Base):
|
||||
return self.login_type == LoginType.OAuth2
|
||||
|
||||
@staticmethod
|
||||
def is_valid_username(name: str) -> bool:
|
||||
def check_username(name: str) -> bool:
|
||||
return bool(valid_username.match(name) and not invalid_username.match(name))
|
||||
|
||||
@staticmethod
|
||||
async def count(db: database.Database):
|
||||
async with db.session() as session:
|
||||
return await session.scalar(sa.select(sa.func.count(User.id)))
|
||||
def check_password(password: str, config: Config) -> bool:
|
||||
if len(password) < config.security.password_min_length:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def by_name(name: str, db: database.Database):
|
||||
async with db.session() as session:
|
||||
return (
|
||||
await session.scalars(sa.select(User).where(User.name == name))
|
||||
).first()
|
||||
async def count(session: SessionContext) -> Optional[int]:
|
||||
return await session.scalar(sa.select(sa.func.count(User.id)))
|
||||
|
||||
@staticmethod
|
||||
async def by_email(email: str, db: database.Database):
|
||||
async with db.session() as session:
|
||||
return (
|
||||
await session.scalars(sa.select(User).where(User.email == email))
|
||||
).first()
|
||||
async def by_name(
|
||||
name: str, session: SessionContext, with_lower: bool = False
|
||||
) -> Optional[Self]:
|
||||
if with_lower:
|
||||
query = User.lower_name == name.lower()
|
||||
else:
|
||||
query = User.name == name
|
||||
return (await session.scalars(sa.select(User).where(query))).first()
|
||||
|
||||
@staticmethod
|
||||
async def by_id(id: UUID, db: database.Database):
|
||||
async with db.session() as session:
|
||||
return (await session.scalars(sa.select(User).where(User.id == id))).first()
|
||||
async def by_email(email: str, session: SessionContext) -> Optional[Self]:
|
||||
return (
|
||||
await session.scalars(sa.select(User).where(User.email == email))
|
||||
).first()
|
||||
|
||||
async def remove(self, db: database.Database):
|
||||
async with db.session() as session:
|
||||
await session.delete(self)
|
||||
await session.commit()
|
||||
@staticmethod
|
||||
async def by_id(id: UUID, session: SessionContext) -> Optional[Self]:
|
||||
return (await session.scalars(sa.select(User).where(User.id == id))).first()
|
||||
|
||||
async def edit_name(self, name: str, session: SessionContext) -> Self:
|
||||
if not User.check_username(name):
|
||||
raise UserError(f"Invalid username: {name}")
|
||||
|
||||
self.name = name
|
||||
self.lower_name = name.lower()
|
||||
session.add(self)
|
||||
await session.flush()
|
||||
|
||||
return self
|
||||
|
||||
async def edit_password(
|
||||
self, password: str, session: SessionContext, config: Config
|
||||
) -> Self:
|
||||
if not User.check_password(password, config):
|
||||
raise UserError("Invalid password")
|
||||
|
||||
self.hashed_password = security.hash_password(
|
||||
password, algo=config.security.password_hash_algo
|
||||
)
|
||||
|
||||
session.add(self)
|
||||
await session.flush()
|
||||
|
||||
return self
|
||||
|
||||
async def edit_email(self):
|
||||
pass
|
||||
|
||||
def info(self) -> "UserInfo":
|
||||
user_info = UserInfo.model_validate(self)
|
||||
|
||||
if user_info.is_email_private:
|
||||
user_info.email = None
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
class UserCredentials(BaseModel):
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
@ -6,77 +5,92 @@ from materia import security
|
||||
from materia.routers.middleware import Context
|
||||
from materia.models import LoginType, User, UserCredentials
|
||||
|
||||
router = APIRouter(tags = ["auth"])
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/auth/signup")
|
||||
async def signup(body: UserCredentials, ctx: Context = Depends()):
|
||||
if not User.is_valid_username(body.name):
|
||||
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Invalid username")
|
||||
if await User.by_name(body.name, ctx.database) is not None:
|
||||
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "User already exists")
|
||||
if await User.by_email(body.email, ctx.database) is not None: # type: ignore
|
||||
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Email already used")
|
||||
if len(body.password) < ctx.config.security.password_min_length:
|
||||
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = f"Password is too short (minimum length {ctx.config.security.password_min_length})")
|
||||
|
||||
count: Optional[int] = await User.count(ctx.database)
|
||||
|
||||
new_user = User(
|
||||
name = body.name,
|
||||
lower_name = body.name.lower(),
|
||||
full_name = body.name,
|
||||
email = body.email,
|
||||
hashed_password = security.hash_password(body.password, algo = ctx.config.security.password_hash_algo),
|
||||
login_type = LoginType.Plain,
|
||||
# first registered user is admin
|
||||
is_admin = count == 0
|
||||
)
|
||||
|
||||
async with ctx.database.session() as session:
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
if not User.check_username(body.name):
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid username"
|
||||
)
|
||||
if not User.check_password(body.password, ctx.config):
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Password is too short (minimum length {ctx.config.security.password_min_length})",
|
||||
)
|
||||
if await User.by_name(body.name, session, with_lower=True):
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User already exists"
|
||||
)
|
||||
if await User.by_email(body.email, session): # type: ignore
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email already used"
|
||||
)
|
||||
|
||||
count: Optional[int] = await User.count(session)
|
||||
|
||||
await User(
|
||||
name=body.name,
|
||||
lower_name=body.name.lower(),
|
||||
full_name=body.name,
|
||||
email=body.email,
|
||||
hashed_password=security.hash_password(
|
||||
body.password, algo=ctx.config.security.password_hash_algo
|
||||
),
|
||||
login_type=LoginType.Plain,
|
||||
# first registered user is admin
|
||||
is_admin=count == 0,
|
||||
).new(session)
|
||||
|
||||
|
||||
@router.post("/auth/signin")
|
||||
async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()):
|
||||
if (current_user := await User.by_name(body.name, ctx.database)) is None:
|
||||
if (current_user := await User.by_email(str(body.email), ctx.database)) is None:
|
||||
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid email")
|
||||
if not security.validate_password(body.password, current_user.hashed_password, algo = ctx.config.security.password_hash_algo):
|
||||
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid password")
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid email")
|
||||
if not security.validate_password(
|
||||
body.password,
|
||||
current_user.hashed_password,
|
||||
algo=ctx.config.security.password_hash_algo,
|
||||
):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
||||
|
||||
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
|
||||
secret = ctx.config.oauth2.jwt_secret if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"] else ctx.config.oauth2.jwt_signing_key
|
||||
secret = (
|
||||
ctx.config.oauth2.jwt_secret
|
||||
if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]
|
||||
else ctx.config.oauth2.jwt_signing_key
|
||||
)
|
||||
access_token = security.generate_token(
|
||||
str(current_user.id),
|
||||
str(current_user.id),
|
||||
str(secret),
|
||||
ctx.config.oauth2.access_token_lifetime,
|
||||
issuer
|
||||
issuer,
|
||||
)
|
||||
refresh_token = security.generate_token(
|
||||
"",
|
||||
str(secret),
|
||||
ctx.config.oauth2.refresh_token_lifetime,
|
||||
issuer
|
||||
"", str(secret), ctx.config.oauth2.refresh_token_lifetime, issuer
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
ctx.config.security.cookie_access_token_name,
|
||||
value = access_token,
|
||||
max_age = ctx.config.oauth2.access_token_lifetime,
|
||||
secure = True,
|
||||
httponly = ctx.config.security.cookie_http_only,
|
||||
samesite = "lax"
|
||||
ctx.config.security.cookie_access_token_name,
|
||||
value=access_token,
|
||||
max_age=ctx.config.oauth2.access_token_lifetime,
|
||||
secure=True,
|
||||
httponly=ctx.config.security.cookie_http_only,
|
||||
samesite="lax",
|
||||
)
|
||||
response.set_cookie(
|
||||
ctx.config.security.cookie_refresh_token_name,
|
||||
value = refresh_token,
|
||||
max_age = ctx.config.oauth2.refresh_token_lifetime,
|
||||
secure = True,
|
||||
httponly = ctx.config.security.cookie_http_only,
|
||||
samesite = "lax"
|
||||
ctx.config.security.cookie_refresh_token_name,
|
||||
value=refresh_token,
|
||||
max_age=ctx.config.oauth2.refresh_token_lifetime,
|
||||
secure=True,
|
||||
httponly=ctx.config.security.cookie_http_only,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth/signout")
|
||||
async def signout(response: Response, ctx: Context = Depends()):
|
||||
response.delete_cookie(ctx.config.security.cookie_access_token_name)
|
||||
|
@ -43,7 +43,6 @@ async def create(
|
||||
repository_id=user.repository.id,
|
||||
parent_id=current_directory.id if current_directory else None,
|
||||
name=part,
|
||||
path=None if current_path == Path() else str(current_path),
|
||||
)
|
||||
|
||||
try:
|
||||
|
@ -21,21 +21,19 @@ router = APIRouter(tags=["repository"])
|
||||
async def create(
|
||||
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
|
||||
):
|
||||
repository_path = Config.data_dir() / "repository" / user.lower_name
|
||||
async with ctx.database.session() as session:
|
||||
if await Repository.by_user(user, session):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
|
||||
|
||||
if await Repository.by_user_id(user.id, ctx.database):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
|
||||
|
||||
repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity)
|
||||
|
||||
try:
|
||||
repository_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository"
|
||||
)
|
||||
|
||||
await repository.create(ctx.database)
|
||||
async with ctx.database.session() as session:
|
||||
try:
|
||||
await Repository(
|
||||
user_id=user.id, capacity=ctx.config.repository.capacity
|
||||
).new(session)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=" ".join(e.args)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/repository", response_model=RepositoryInfo)
|
||||
|
@ -1,11 +1,22 @@
|
||||
import pytest_asyncio
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from materia.config import Config
|
||||
from materia.models import Database, User, LoginType, Repository, Directory
|
||||
from materia.models import (
|
||||
Database,
|
||||
User,
|
||||
LoginType,
|
||||
Repository,
|
||||
Directory,
|
||||
RepositoryError,
|
||||
)
|
||||
from materia.models.base import Base
|
||||
from materia.models.database import SessionContext
|
||||
from materia import security
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.orm.session import make_transient
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@ -46,7 +57,6 @@ async def db(config: Config, request) -> Database:
|
||||
|
||||
await database.dispose()
|
||||
|
||||
# database_postgres = await Database.new(config_postgres.database.url())
|
||||
async with database_postgres.connection() as connection:
|
||||
await connection.execution_options(isolation_level="AUTOCOMMIT")
|
||||
await connection.execute(sa.text("drop database pytest")),
|
||||
@ -55,11 +65,23 @@ async def db(config: Config, request) -> Database:
|
||||
await database_postgres.dispose()
|
||||
|
||||
|
||||
"""
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrations(db):
|
||||
await db.run_migrations()
|
||||
await db.rollback_migrations()
|
||||
"""
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def setup_db(db: Database, request):
|
||||
await db.run_migrations()
|
||||
async with db.connection() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.commit()
|
||||
yield
|
||||
# await db.rollback_migrations()
|
||||
async with db.connection() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
await connection.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
@ -70,6 +92,7 @@ async def session(db: Database, request):
|
||||
await session.close()
|
||||
|
||||
|
||||
"""
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def user(config: Config, session) -> User:
|
||||
test_user = User(
|
||||
@ -93,13 +116,14 @@ async def user(config: Config, session) -> User:
|
||||
async with db.session() as session:
|
||||
await session.delete(test_user)
|
||||
await session.flush()
|
||||
"""
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def data(config: Config):
|
||||
class TestData:
|
||||
user = User(
|
||||
name="pytest",
|
||||
name="PyTest",
|
||||
lower_name="pytest",
|
||||
email="pytest@example.com",
|
||||
hashed_password=security.hash_password(
|
||||
@ -113,28 +137,58 @@ async def data(config: Config):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user(data, session):
|
||||
async def test_user(data, session: SessionContext, config: Config):
|
||||
# simple
|
||||
session.add(data.user)
|
||||
await session.flush()
|
||||
|
||||
assert data.user.id is not None
|
||||
assert security.validate_password("iampytest", data.user.hashed_password)
|
||||
|
||||
await session.rollback()
|
||||
|
||||
# methods
|
||||
await data.user.new(session, config)
|
||||
|
||||
assert data.user.id is not None
|
||||
assert await data.user.count(session) == 1
|
||||
assert await User.by_name("PyTest", session) == data.user
|
||||
assert await User.by_email("pytest@example.com", session) == data.user
|
||||
|
||||
await data.user.edit_name("AsyncPyTest", session)
|
||||
assert await User.by_name("asyncpytest", session, with_lower=True) == data.user
|
||||
|
||||
await data.user.remove(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository(data, session, config):
|
||||
async def test_repository(data, tmpdir, session: SessionContext, config: Config):
|
||||
config.application.working_directory = Path(tmpdir)
|
||||
|
||||
session.add(data.user)
|
||||
await session.flush()
|
||||
|
||||
repository = Repository(user_id=data.user.id, capacity=config.repository.capacity)
|
||||
session.add(repository)
|
||||
await session.flush()
|
||||
repository = await Repository(
|
||||
user_id=data.user.id, capacity=config.repository.capacity
|
||||
).new(session, config)
|
||||
|
||||
assert repository
|
||||
assert repository.id is not None
|
||||
assert (await repository.path(session, config)).exists()
|
||||
assert await Repository.from_user(data.user, session) == repository
|
||||
|
||||
await repository.remove(session, config)
|
||||
make_transient(repository)
|
||||
session.add(repository)
|
||||
await session.flush()
|
||||
with pytest.raises(RepositoryError):
|
||||
await repository.remove(session, config)
|
||||
assert not (await repository.path(session, config)).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory(data, session, config):
|
||||
async def test_directory(data, tmpdir, session: SessionContext, config: Config):
|
||||
# setup
|
||||
session.add(data.user)
|
||||
await session.flush()
|
||||
|
||||
@ -142,12 +196,11 @@ async def test_directory(data, session, config):
|
||||
session.add(repository)
|
||||
await session.flush()
|
||||
|
||||
directory = Directory(
|
||||
repository_id=repository.id, parent_id=None, name="test1", path=None
|
||||
)
|
||||
directory = Directory(repository_id=repository.id, parent_id=None, name="test1")
|
||||
session.add(directory)
|
||||
await session.flush()
|
||||
|
||||
# simple
|
||||
assert directory.id is not None
|
||||
assert (
|
||||
await session.scalars(
|
||||
@ -155,17 +208,16 @@ async def test_directory(data, session, config):
|
||||
sa.and_(
|
||||
Directory.repository_id == repository.id,
|
||||
Directory.name == "test1",
|
||||
Directory.path.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
).first() == directory
|
||||
|
||||
# nested simple
|
||||
nested_directory = Directory(
|
||||
repository_id=repository.id,
|
||||
parent_id=directory.id,
|
||||
name="test_nested",
|
||||
path="test1",
|
||||
)
|
||||
session.add(nested_directory)
|
||||
await session.flush()
|
||||
@ -177,7 +229,6 @@ async def test_directory(data, session, config):
|
||||
sa.and_(
|
||||
Directory.repository_id == repository.id,
|
||||
Directory.name == "test_nested",
|
||||
Directory.path == "test1",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -5,7 +5,7 @@
|
||||
groups = ["default", "dev"]
|
||||
strategy = ["cross_platform", "inherit_metadata"]
|
||||
lock_version = "4.4.1"
|
||||
content_hash = "sha256:4122146bc4848501b79e4e97551c7835f5559cc7294aa8ec3161a9c2fd86985f"
|
||||
content_hash = "sha256:16bedb3de70622af531e01dee2c2773d108a005caf9fa9d2fbe9042267602ef6"
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
@ -19,18 +19,12 @@ dependencies = [
|
||||
"packaging>=22.0",
|
||||
"pathspec>=0.9.0",
|
||||
"platformdirs>=2",
|
||||
"tomli>=1.1.0; python_version < \"3.11\"",
|
||||
"typing-extensions>=4.0.1; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"},
|
||||
{file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"},
|
||||
{file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"},
|
||||
{file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"},
|
||||
{file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"},
|
||||
{file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"},
|
||||
{file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"},
|
||||
{file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"},
|
||||
{file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
|
||||
{file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
|
||||
{file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
|
||||
{file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
|
||||
{file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
|
||||
{file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"},
|
||||
]
|
||||
@ -61,18 +55,6 @@ files = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.2"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Backport of PEP 654 (exception groups)"
|
||||
groups = ["dev"]
|
||||
marker = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
|
||||
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
@ -173,41 +155,15 @@ summary = "pytest: simple powerful testing with Python"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"colorama; sys_platform == \"win32\"",
|
||||
"exceptiongroup>=1.0.0rc8; python_version < \"3.11\"",
|
||||
"iniconfig",
|
||||
"packaging",
|
||||
"pluggy<2.0,>=0.12",
|
||||
"tomli>=1.0.0; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
|
||||
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A lil' TOML parser"
|
||||
groups = ["dev"]
|
||||
marker = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
groups = ["dev"]
|
||||
marker = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.1.0"
|
||||
|
@ -8,7 +8,7 @@ authors = [
|
||||
dependencies = [
|
||||
"loguru<1.0.0,>=0.7.2",
|
||||
]
|
||||
requires-python = "<3.12,>=3.10"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user