Compare commits

...

2 Commits

Author SHA1 Message Date
69a1aa2471
update dependencies
and more
2024-08-03 01:01:01 +05:00
727f1b51ee
fix optional package materia-frontend 2024-07-31 18:04:34 +05:00
22 changed files with 1001 additions and 1603 deletions

View File

@ -16,6 +16,37 @@ alembic upgrade head
# Rollback the migration # Rollback the migration
alembic downgrade head 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 # License
**materia** is licensed under the [MIT License](LICENSE). **materia** is licensed under the [MIT License](LICENSE).

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@
... ...
}: { }: {
name = "materia-frontend"; name = "materia-frontend";
version = "0.0.1"; version = "0.0.5";
imports = [ imports = [
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3 dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
@ -94,20 +94,20 @@
}: { }: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm]; imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./materia-web-client/pdm.lock; pdm.lockfile = ./workspaces/frontend/pdm.lock;
pdm.pyproject = ./materia-web-client/pyproject.toml; pdm.pyproject = ./workspaces/frontend/pyproject.toml;
deps = _: { deps = _: {
python = pkgs.python3; python = pkgs.python312;
}; };
mkDerivation = { mkDerivation = {
src = ./workspaces/frontend; src = ./workspaces/frontend;
buildInputs = [ buildInputs = [
pkgs.python3.pkgs.pdm-backend pkgs.python312.pkgs.pdm-backend
]; ];
configurePhase = '' 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 = { module = {
config, config,
lib, lib,
@ -129,20 +132,23 @@
}: { }: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm]; imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./materia-server/pdm.lock; pdm.lockfile = ./pdm.lock;
pdm.pyproject = ./materia-server/pyproject.toml; pdm.pyproject = ./pyproject.toml;
deps = _: { deps = _: {
python = pkgs.python3; python = pkgs.python312;
}; };
mkDerivation = { mkDerivation = {
src = ./materia-server; src = ./.;
buildInputs = [ buildInputs = [
pkgs.python3.pkgs.pdm-backend pkgs.python312.pkgs.pdm-backend
]; ];
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.python3.pkgs.wrapPython pkgs.python312.pkgs.wrapPython
];
propagatedBuildInputs = [
materia-frontend
]; ];
}; };
}; };
@ -151,7 +157,7 @@
license = licenses.mit; license = licenses.mit;
maintainers = with bonLib.maintainers; [L-Nafaryus]; maintainers = with bonLib.maintainers; [L-Nafaryus];
broken = false; broken = false;
mainProgram = "materia-server"; mainProgram = "materia";
}; };
}; };
@ -160,15 +166,8 @@
redis-devel = bonfire.packages.x86_64-linux.redis; 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 { 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++ # greenlet requires libstdc++
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc]; LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
}; };

807
pdm.lock

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,6 @@ dependencies = [
"sqids<1.0.0,>=0.4.1", "sqids<1.0.0,>=0.4.1",
"alembic<2.0.0,>=1.13.1", "alembic<2.0.0,>=1.13.1",
"authlib<2.0.0,>=1.3.0", "authlib<2.0.0,>=1.3.0",
"cryptography<43.0.0,>=42.0.7",
"redis[hiredis]<6.0.0,>=5.0.4", "redis[hiredis]<6.0.0,>=5.0.4",
"aiosmtplib<4.0.0,>=3.0.1", "aiosmtplib<4.0.0,>=3.0.1",
"emails<1.0,>=0.6", "emails<1.0,>=0.6",
@ -31,9 +30,12 @@ dependencies = [
"alembic-postgresql-enum<2.0.0,>=1.2.0", "alembic-postgresql-enum<2.0.0,>=1.2.0",
"gunicorn>=22.0.0", "gunicorn>=22.0.0",
"uvicorn-worker>=0.2.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" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@ -69,10 +71,9 @@ includes = ["src/materia"]
[tool.pdm.scripts] [tool.pdm.scripts]
start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}" start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}"
upgrade.cmd = "alembic -c ./src/materia/alembic.ini upgrade {args:head}" 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;'"
downgrade.shell = "alembic -c ./src/materia/alembic.ini downgrade {args:base}" 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}" rev.cmd = "alembic revision {args:--autogenerate}"
rm-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py" upgrade.cmd = "alembic upgrade {args:head}"
downgrade.cmd = "alembic downgrade {args:base}"
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"

View File

@ -66,6 +66,13 @@ def make_lifespan(config: Config, logger: Logger):
def make_application(config: Config, logger: Logger): def make_application(config: Config, logger: Logger):
try:
import materia_frontend
except ModuleNotFoundError:
logger.warning(
"`materia_frontend` is not installed. No user interface will be served."
)
app = FastAPI( app = FastAPI(
title="materia", title="materia",
version="0.1.0", version="0.1.0",

View File

@ -20,6 +20,7 @@ from materia.models.repository import (
Repository, Repository,
RepositoryInfo, RepositoryInfo,
RepositoryContent, RepositoryContent,
RepositoryError,
) )
from materia.models.directory import Directory, DirectoryLink, DirectoryInfo from materia.models.directory import Directory, DirectoryLink, DirectoryInfo

View File

@ -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 from materia.models.database.cache import Cache, CacheError

View File

@ -1,6 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
from typing import AsyncIterator, Self from typing import AsyncIterator, Self, TypeAlias
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, PostgresDsn from pydantic import BaseModel, PostgresDsn
@ -17,6 +17,8 @@ from alembic.config import Config as AlembicConfig
from alembic.operations import Operations from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script.base import ScriptDirectory from alembic.script.base import ScriptDirectory
import alembic_postgresql_enum
from fastapi import HTTPException
from materia.config import Config from materia.config import Config
from materia.models.base import Base from materia.models.base import Base
@ -32,16 +34,21 @@ class DatabaseMigrationError(Exception):
pass pass
SessionContext: TypeAlias = AsyncIterator[AsyncSession]
SessionMaker: TypeAlias = async_sessionmaker[AsyncSession]
ConnectionContext: TypeAlias = AsyncIterator[AsyncConnection]
class Database: class Database:
def __init__( def __init__(
self, self,
url: PostgresDsn, url: PostgresDsn,
engine: AsyncEngine, engine: AsyncEngine,
sessionmaker: async_sessionmaker[AsyncSession], sessionmaker: SessionMaker,
): ):
self.url: PostgresDsn = url self.url: PostgresDsn = url
self.engine: AsyncEngine = engine self.engine: AsyncEngine = engine
self.sessionmaker: async_sessionmaker[AsyncSession] = sessionmaker self.sessionmaker: SessionMaker = sessionmaker
@staticmethod @staticmethod
async def new( async def new(
@ -81,7 +88,7 @@ class Database:
await self.engine.dispose() await self.engine.dispose()
@asynccontextmanager @asynccontextmanager
async def connection(self) -> AsyncIterator[AsyncConnection]: async def connection(self) -> ConnectionContext:
async with self.engine.connect() as connection: async with self.engine.connect() as connection:
try: try:
yield connection yield connection
@ -90,7 +97,7 @@ class Database:
raise DatabaseError(f"{e}") raise DatabaseError(f"{e}")
@asynccontextmanager @asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]: async def session(self) -> SessionContext:
session = self.sessionmaker() session = self.sessionmaker()
try: try:
@ -98,6 +105,9 @@ class Database:
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
raise DatabaseError(f"{e}") raise DatabaseError(f"{e}")
except HTTPException:
# if the initial exception reaches HTTPException, then everything is handled fine (should be)
await session.rollback()
finally: finally:
await session.close() await session.close()

View File

@ -1,6 +1,7 @@
from time import time from time import time
from typing import List, Optional, Self from typing import List, Optional, Self
from pathlib import Path from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship 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.base import Base
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
class DirectoryError(Exception):
pass
class Directory(Base): class Directory(Base):
@ -24,7 +30,6 @@ class Directory(Base):
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str] name: Mapped[str]
path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default=False) is_public: Mapped[bool] = mapped_column(default=False)
repository: Mapped["Repository"] = relationship(back_populates="directories") repository: Mapped["Repository"] = relationship(back_populates="directories")
@ -35,32 +40,198 @@ class Directory(Base):
files: Mapped[List["File"]] = relationship(back_populates="parent") files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory") link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
@staticmethod async def new(
async def by_path( self,
repository_id: int, path: Path | None, name: str, db: database.Database session: SessionContext,
) -> Self | None: path: Optional[Path] = None,
async with db.session() as session: with_parents: bool = False,
query_path = ( ) -> Optional[Self]:
Directory.path == str(path) session.add(self)
if isinstance(path, Path) await session.flush()
else Directory.path.is_(None) 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( try:
sa.select(Directory).where( current_path.mkdir()
sa.and_( except OSError as e:
Directory.repository_id == repository_id, raise DirectoryError(
Directory.name == name, f"Failed to create directory at /{relative_path}:", *e.args
query_path, )
)
# 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: async with db.session() as session:
await session.delete(self) for part in path.parts:
await session.commit() 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): class DirectoryLink(Base):

View File

@ -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 ###

View File

@ -1,8 +1,8 @@
"""empty message """empty message
Revision ID: 939b37d98be0 Revision ID: bf2ef6c7ab70
Revises: Revises:
Create Date: 2024-06-24 15:39:38.380581 Create Date: 2024-08-02 18:37:01.697075
""" """
from typing import Sequence, Union from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '939b37d98be0' revision: str = 'bf2ef6c7ab70'
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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('id', sa.BigInteger(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('capacity', sa.BigInteger(), 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') sa.PrimaryKeyConstraint('id')
) )
op.create_table('directory', op.create_table('directory',
@ -75,7 +75,6 @@ def upgrade() -> None:
sa.Column('created', sa.BigInteger(), nullable=False), sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('updated', sa.BigInteger(), nullable=False), sa.Column('updated', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(), 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.Column('is_public', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['repository_id'], ['repository.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('path', sa.String(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False), sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('size', sa.BigInteger(), 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.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )

View File

@ -1,6 +1,8 @@
from time import time from time import time
from typing import List, Self, Optional from typing import List, Self, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship 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.base import Base
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
class RepositoryError(Exception):
pass
class Repository(Base): class Repository(Base):
@ -23,6 +31,57 @@ class Repository(Base):
directories: Mapped[List["Directory"]] = relationship(back_populates="repository") directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
files: Mapped[List["File"]] = 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: def to_dict(self) -> dict:
return { return {
k: getattr(self, k) k: getattr(self, k)
@ -30,33 +89,20 @@ class Repository(Base):
if isinstance(v, InstrumentedAttribute) if isinstance(v, InstrumentedAttribute)
} }
async def create(self, db: database.Database): async def update(self, session: SessionContext):
async with db.session() as session: await session.execute(
session.add(self) sa.update(Repository).values(self.to_dict()).where(Repository.id == self.id)
await session.commit() )
await session.flush()
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()
@staticmethod @staticmethod
async def by_user_id(user_id: UUID, db: database.Database) -> Self | None: async def from_user(user: "User", session: SessionContext) -> Optional[Self]:
async with db.session() as session: session.add(user)
return ( await session.refresh(user, attribute_names=["repository"])
await session.scalars( return user.repository
sa.select(Repository).where(Repository.user_id == user_id)
)
).first()
async def remove(self, db: database.Database): async def info(self) -> "RepositoryInfo":
async with db.session() as session: return RepositoryInfo.model_validate(self)
await session.delete(self)
await session.commit()
class RepositoryInfo(BaseModel): class RepositoryInfo(BaseModel):

View File

@ -1,5 +1,5 @@
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from typing import Optional from typing import Optional, Self
import time import time
import re import re
@ -9,15 +9,22 @@ from sqlalchemy import BigInteger, Enum
from sqlalchemy.orm import mapped_column, Mapped, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship
import sqlalchemy as sa import sqlalchemy as sa
from materia import security
from materia.models.base import Base from materia.models.base import Base
from materia.models.auth.source import LoginType from materia.models.auth.source import LoginType
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
from loguru import logger from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$") valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$") invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class UserError(Exception):
pass
class User(Base): class User(Base):
__tablename__ = "user" __tablename__ = "user"
@ -43,6 +50,23 @@ class User(Base):
repository: Mapped["Repository"] = relationship(back_populates="user") 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): def update_last_login(self):
self.last_login = int(time.time()) self.last_login = int(time.time())
@ -53,37 +77,74 @@ class User(Base):
return self.login_type == LoginType.OAuth2 return self.login_type == LoginType.OAuth2
@staticmethod @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)) return bool(valid_username.match(name) and not invalid_username.match(name))
@staticmethod @staticmethod
async def count(db: database.Database): def check_password(password: str, config: Config) -> bool:
async with db.session() as session: if len(password) < config.security.password_min_length:
return await session.scalar(sa.select(sa.func.count(User.id))) return False
@staticmethod @staticmethod
async def by_name(name: str, db: database.Database): async def count(session: SessionContext) -> Optional[int]:
async with db.session() as session: return await session.scalar(sa.select(sa.func.count(User.id)))
return (
await session.scalars(sa.select(User).where(User.name == name))
).first()
@staticmethod @staticmethod
async def by_email(email: str, db: database.Database): async def by_name(
async with db.session() as session: name: str, session: SessionContext, with_lower: bool = False
return ( ) -> Optional[Self]:
await session.scalars(sa.select(User).where(User.email == email)) if with_lower:
).first() query = User.lower_name == name.lower()
else:
query = User.name == name
return (await session.scalars(sa.select(User).where(query))).first()
@staticmethod @staticmethod
async def by_id(id: UUID, db: database.Database): async def by_email(email: str, session: SessionContext) -> Optional[Self]:
async with db.session() as session: return (
return (await session.scalars(sa.select(User).where(User.id == id))).first() await session.scalars(sa.select(User).where(User.email == email))
).first()
async def remove(self, db: database.Database): @staticmethod
async with db.session() as session: async def by_id(id: UUID, session: SessionContext) -> Optional[Self]:
await session.delete(self) return (await session.scalars(sa.select(User).where(User.id == id))).first()
await session.commit()
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): class UserCredentials(BaseModel):

View File

@ -1,4 +1,3 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
@ -6,77 +5,92 @@ from materia import security
from materia.routers.middleware import Context from materia.routers.middleware import Context
from materia.models import LoginType, User, UserCredentials from materia.models import LoginType, User, UserCredentials
router = APIRouter(tags = ["auth"]) router = APIRouter(tags=["auth"])
@router.post("/auth/signup") @router.post("/auth/signup")
async def signup(body: UserCredentials, ctx: Context = Depends()): 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: async with ctx.database.session() as session:
session.add(new_user) if not User.check_username(body.name):
await session.commit() 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") @router.post("/auth/signin")
async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()): 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_name(body.name, ctx.database)) is None:
if (current_user := await User.by_email(str(body.email), 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") 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): if not security.validate_password(
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid 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) 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( access_token = security.generate_token(
str(current_user.id), str(current_user.id),
str(secret), str(secret),
ctx.config.oauth2.access_token_lifetime, ctx.config.oauth2.access_token_lifetime,
issuer issuer,
) )
refresh_token = security.generate_token( 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( response.set_cookie(
ctx.config.security.cookie_access_token_name, ctx.config.security.cookie_access_token_name,
value = access_token, value=access_token,
max_age = ctx.config.oauth2.access_token_lifetime, max_age=ctx.config.oauth2.access_token_lifetime,
secure = True, secure=True,
httponly = ctx.config.security.cookie_http_only, httponly=ctx.config.security.cookie_http_only,
samesite = "lax" samesite="lax",
) )
response.set_cookie( response.set_cookie(
ctx.config.security.cookie_refresh_token_name, ctx.config.security.cookie_refresh_token_name,
value = refresh_token, value=refresh_token,
max_age = ctx.config.oauth2.refresh_token_lifetime, max_age=ctx.config.oauth2.refresh_token_lifetime,
secure = True, secure=True,
httponly = ctx.config.security.cookie_http_only, httponly=ctx.config.security.cookie_http_only,
samesite = "lax" samesite="lax",
) )
@router.get("/auth/signout") @router.get("/auth/signout")
async def signout(response: Response, ctx: Context = Depends()): async def signout(response: Response, ctx: Context = Depends()):
response.delete_cookie(ctx.config.security.cookie_access_token_name) response.delete_cookie(ctx.config.security.cookie_access_token_name)

View File

@ -43,7 +43,6 @@ async def create(
repository_id=user.repository.id, repository_id=user.repository.id,
parent_id=current_directory.id if current_directory else None, parent_id=current_directory.id if current_directory else None,
name=part, name=part,
path=None if current_path == Path() else str(current_path),
) )
try: try:

View File

@ -21,21 +21,19 @@ router = APIRouter(tags=["repository"])
async def create( async def create(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends() 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): async with ctx.database.session() as session:
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists") try:
await Repository(
repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity) user_id=user.id, capacity=ctx.config.repository.capacity
).new(session)
try: except Exception as e:
repository_path.mkdir(parents=True, exist_ok=True) raise HTTPException(
except OSError: status.HTTP_500_INTERNAL_SERVER_ERROR, detail=" ".join(e.args)
raise HTTPException( )
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository"
)
await repository.create(ctx.database)
@router.get("/repository", response_model=RepositoryInfo) @router.get("/repository", response_model=RepositoryInfo)

View File

@ -1,5 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image from PIL import Image
import io import io
from pathlib import Path from pathlib import Path
@ -7,7 +6,6 @@ import mimetypes
from materia.routers import middleware from materia.routers import middleware
from materia.config import Config from materia.config import Config
import materia_frontend
router = APIRouter(tags=["resources"], prefix="/resources") router = APIRouter(tags=["resources"], prefix="/resources")
@ -41,16 +39,22 @@ async def avatar(
return Response(content=buffer.getvalue(), media_type=Image.MIME[format]) return Response(content=buffer.getvalue(), media_type=Image.MIME[format])
@router.get("/assets/{filename}") try:
async def assets(filename: str): import materia_frontend
path = Path(materia_frontend.__path__[0]).joinpath( except ModuleNotFoundError:
"dist", "resources", "assets", filename pass
) else:
if not path.exists(): @router.get("/assets/{filename}")
return Response(status_code=status.HTTP_404_NOT_FOUND) async def assets(filename: str):
path = Path(materia_frontend.__path__[0]).joinpath(
"dist", "resources", "assets", filename
)
content = path.read_bytes() if not path.exists():
mime = mimetypes.guess_type(path)[0] return Response(status_code=status.HTTP_404_NOT_FOUND)
return Response(content, media_type=mime) content = path.read_bytes()
mime = mimetypes.guess_type(path)[0]
return Response(content, media_type=mime)

View File

@ -2,13 +2,19 @@ from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import materia_frontend
router = APIRouter(tags=["root"]) router = APIRouter(tags=["root"])
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
try:
import materia_frontend
except ModuleNotFoundError:
pass
else:
@router.get("/{spa:path}", response_class=HTMLResponse) templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
async def root(request: Request):
return templates.TemplateResponse("base.html", {"request": request, "view": "app"}) @router.get("/{spa:path}", response_class=HTMLResponse)
async def root(request: Request):
return templates.TemplateResponse(
"base.html", {"request": request, "view": "app"}
)

View File

@ -1,11 +1,22 @@
import pytest_asyncio import pytest_asyncio
import pytest import pytest
import os import os
from pathlib import Path
from materia.config import Config 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 from materia import security
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from sqlalchemy.orm.session import make_transient
from dataclasses import dataclass from dataclasses import dataclass
@ -46,7 +57,6 @@ async def db(config: Config, request) -> Database:
await database.dispose() await database.dispose()
# database_postgres = await Database.new(config_postgres.database.url())
async with database_postgres.connection() as connection: async with database_postgres.connection() as connection:
await connection.execution_options(isolation_level="AUTOCOMMIT") await connection.execution_options(isolation_level="AUTOCOMMIT")
await connection.execute(sa.text("drop database pytest")), await connection.execute(sa.text("drop database pytest")),
@ -55,11 +65,23 @@ async def db(config: Config, request) -> Database:
await database_postgres.dispose() 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) @pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db(db: Database, request): 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 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) @pytest_asyncio.fixture(autouse=True)
@ -70,6 +92,7 @@ async def session(db: Database, request):
await session.close() await session.close()
"""
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")
async def user(config: Config, session) -> User: async def user(config: Config, session) -> User:
test_user = User( test_user = User(
@ -93,13 +116,14 @@ async def user(config: Config, session) -> User:
async with db.session() as session: async with db.session() as session:
await session.delete(test_user) await session.delete(test_user)
await session.flush() await session.flush()
"""
@pytest_asyncio.fixture @pytest_asyncio.fixture(scope="function")
async def data(config: Config): async def data(config: Config):
class TestData: class TestData:
user = User( user = User(
name="pytest", name="PyTest",
lower_name="pytest", lower_name="pytest",
email="pytest@example.com", email="pytest@example.com",
hashed_password=security.hash_password( hashed_password=security.hash_password(
@ -113,28 +137,58 @@ async def data(config: Config):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user(data, session): async def test_user(data, session: SessionContext, config: Config):
# simple
session.add(data.user) session.add(data.user)
await session.flush() await session.flush()
assert data.user.id is not None assert data.user.id is not None
assert security.validate_password("iampytest", data.user.hashed_password) 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 @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) session.add(data.user)
await session.flush() await session.flush()
repository = Repository(user_id=data.user.id, capacity=config.repository.capacity) repository = await Repository(
session.add(repository) user_id=data.user.id, capacity=config.repository.capacity
await session.flush() ).new(session, config)
assert repository
assert repository.id is not None 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 @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) session.add(data.user)
await session.flush() await session.flush()
@ -142,12 +196,11 @@ async def test_directory(data, session, config):
session.add(repository) session.add(repository)
await session.flush() await session.flush()
directory = Directory( directory = Directory(repository_id=repository.id, parent_id=None, name="test1")
repository_id=repository.id, parent_id=None, name="test1", path=None
)
session.add(directory) session.add(directory)
await session.flush() await session.flush()
# simple
assert directory.id is not None assert directory.id is not None
assert ( assert (
await session.scalars( await session.scalars(
@ -155,17 +208,16 @@ async def test_directory(data, session, config):
sa.and_( sa.and_(
Directory.repository_id == repository.id, Directory.repository_id == repository.id,
Directory.name == "test1", Directory.name == "test1",
Directory.path.is_(None),
) )
) )
) )
).first() == directory ).first() == directory
# nested simple
nested_directory = Directory( nested_directory = Directory(
repository_id=repository.id, repository_id=repository.id,
parent_id=directory.id, parent_id=directory.id,
name="test_nested", name="test_nested",
path="test1",
) )
session.add(nested_directory) session.add(nested_directory)
await session.flush() await session.flush()
@ -177,7 +229,6 @@ async def test_directory(data, session, config):
sa.and_( sa.and_(
Directory.repository_id == repository.id, Directory.repository_id == repository.id,
Directory.name == "test_nested", Directory.name == "test_nested",
Directory.path == "test1",
) )
) )
) )

View File

@ -5,7 +5,7 @@
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["cross_platform", "inherit_metadata"] strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:4122146bc4848501b79e4e97551c7835f5559cc7294aa8ec3161a9c2fd86985f" content_hash = "sha256:16bedb3de70622af531e01dee2c2773d108a005caf9fa9d2fbe9042267602ef6"
[[package]] [[package]]
name = "black" name = "black"
@ -19,18 +19,12 @@ dependencies = [
"packaging>=22.0", "packaging>=22.0",
"pathspec>=0.9.0", "pathspec>=0.9.0",
"platformdirs>=2", "platformdirs>=2",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.0.1; python_version < \"3.11\"",
] ]
files = [ files = [
{file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
{file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
{file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
{file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
{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-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
{file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, {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"}, {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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@ -173,41 +155,15 @@ summary = "pytest: simple powerful testing with Python"
groups = ["dev"] groups = ["dev"]
dependencies = [ dependencies = [
"colorama; sys_platform == \"win32\"", "colorama; sys_platform == \"win32\"",
"exceptiongroup>=1.0.0rc8; python_version < \"3.11\"",
"iniconfig", "iniconfig",
"packaging", "packaging",
"pluggy<2.0,>=0.12", "pluggy<2.0,>=0.12",
"tomli>=1.0.0; python_version < \"3.11\"",
] ]
files = [ files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, {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]] [[package]]
name = "win32-setctime" name = "win32-setctime"
version = "1.1.0" version = "1.1.0"

View File

@ -8,7 +8,7 @@ authors = [
dependencies = [ dependencies = [
"loguru<1.0.0,>=0.7.2", "loguru<1.0.0,>=0.7.2",
] ]
requires-python = "<3.12,>=3.10" requires-python = ">=3.12,<3.13"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}