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
|
# 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).
|
||||||
|
880
flake.lock
generated
880
flake.lock
generated
File diff suppressed because it is too large
Load Diff
43
flake.nix
43
flake.nix
@ -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];
|
||||||
};
|
};
|
||||||
|
@ -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}"
|
||||||
|
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}"
|
upgrade.cmd = "alembic upgrade {args:head}"
|
||||||
downgrade.cmd = "alembic downgrade {args:base}"
|
downgrade.cmd = "alembic downgrade {args:base}"
|
||||||
rev.cmd = "alembic revision {args:--autogenerate}"
|
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
|
||||||
rm-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
"""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')
|
||||||
)
|
)
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
54
workspaces/frontend/pdm.lock
generated
54
workspaces/frontend/pdm.lock
generated
@ -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"
|
||||||
|
@ -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"}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user