Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion etc/docker-entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ run_service() {
export RUFF_CACHE_DIR=/tmp/.ruff
ruff check .
print_caption "Mypy check"
mypy .
mypy --cache-dir=/tmp/.mypy .
;;
*)
echo "APP_SERVICE environment variable is unexpected or was not provided (APP_SERVICE='${APP_SERVICE}')" >&2
Expand Down
47 changes: 27 additions & 20 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,35 @@ version = "0.1.0"
description = "Release Agent (some helper system to manage releases)"
requires-python = ">=3.13,<3.14"
dependencies = [
"fastapi==0.123.8",
"fastapi==0.136.3",
"itsdangerous==2.2.0", # required by session engine
"uvicorn==0.38.0",
"pydantic==2.12.5",
"pydantic-settings==2.12.0",
"httpx[socks]==0.28.1",
"sqlalchemy[asyncio]==2.0.44",
"sqladmin==0.22.0",
"alembic==1.17.2",
"uvicorn==0.49.0",
"pydantic==2.13.4",
"pydantic-settings==2.14.1",
"httpx2[socks]==2.3.0",
"sqlalchemy[asyncio]==2.0.50",
"sqladmin==0.27.2",
"alembic==1.18.4",
"asyncpg==0.31.0",
"pyjwt==2.10.1",
"pyjwt==2.13.0",
"pycryptodomex==3.23.0", # for API key encryptions
"uvloop==0.22.1",
"redis==7.1.0", # for Redis cache backend
"clickhouse-connect==0.10.0", # for ClickHouse analytics
"redis==8.0.0", # for Redis cache backend
"clickhouse-connect[async]==1.2.0", # for ClickHouse analytics
]

[dependency-groups]
dev = [
"types-WTForms>=3.2,<4",
"pytest>=8.3,<9",
"pytest-asyncio>=1.2.0,<1.3",
"coverage>=7.10,<7.11",
"black>=25.1,<26.0",
"ruff>=0.13,<0.15",
"mypy>=0.18,<1.19",
"types-wtforms==3.2.1.20260518",
"pytest==9.0.3",
"pytest-asyncio==1.4.0",
"coverage==7.14.1",
"black==26.5.1",
"ruff==0.15.16",
"mypy==2.1.0",
# pycharm requires:
"setuptools>=78.1,<81.0",
"pip<26",
"setuptools==82.0.1",
"pip==26.1.2",
]

# === Ruff settings ===
Expand Down Expand Up @@ -78,11 +78,18 @@ target-version = ["py313"]
strict = true
exclude = ["src/tests", "src/migrations", ".local", "test_*"]

[[tool.mypy.overrides]]
module = ["clickhouse_connect", "clickhouse_connect.*"]
ignore_missing_imports = true

[tool.pytest.ini_options]
testpaths = ["src/tests"]
python_files = ["test_*.py"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:The 'u' type code is deprecated and will be removed in Python 3\\.16:DeprecationWarning",
]

[tool.coverage.report]
exclude_also = [
Expand Down
2 changes: 1 addition & 1 deletion src/db/clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async def close_connection(self) -> None:
return

try:
await self._async_client.close() # type: ignore
await self._async_client.close()
except Exception as e:
logger.error("[CH] Error during connection close: %r", e)
else:
Expand Down
2 changes: 1 addition & 1 deletion src/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class Release(BaseModel):
version: Mapped[str] = mapped_column(sa.String(32), nullable=False, unique=True)
notes: Mapped[str] = mapped_column(sa.Text, nullable=False)
url: Mapped[str] = mapped_column(sa.String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.true())
is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.false())
published_at: Mapped[datetime] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
updated_at: Mapped[datetime] = mapped_column(nullable=True, onupdate=utcnow)
Expand Down
3 changes: 1 addition & 2 deletions src/db/redis.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
from typing import Awaitable, cast

import redis.asyncio as aioredis

Expand Down Expand Up @@ -50,7 +49,7 @@ async def _ping_connection(self) -> None:
logger.info("Redis: Pinging connection to %s...", connection_info)

try:
pingable: bool = await cast(Awaitable[bool], self.client.ping())
pingable: bool = await self.client.ping()
if not pingable:
raise aioredis.ConnectionError("Unable to ping Redis server")

Expand Down
2 changes: 2 additions & 0 deletions src/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def run_migrations_offline() -> None:
literal_binds=True,
dialect_opts={"paramstyle": "named"},
process_revision_directives=process_revision_directives,
compare_server_default=True,
)

with context.begin_transaction():
Expand All @@ -58,6 +59,7 @@ def do_run_migrations(connection: Connection) -> None:
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
compare_server_default=True,
)

with context.begin_transaction():
Expand Down
40 changes: 40 additions & 0 deletions src/migrations/versions/0003_releases_is_active_server_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Releases: change is_active server default

Revision ID: 0003
Revises: 0002
Create Date: 2026-06-10 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.alter_column(
"releases",
"is_active",
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.false(),
)


def downgrade() -> None:
"""Downgrade schema."""
op.alter_column(
"releases",
"is_active",
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.true(),
)
4 changes: 2 additions & 2 deletions src/modules/admin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ async def create(self, request: Request) -> Response:

return response

@staticmethod
def get_save_redirect_url(
self,
request: Request,
form: FormData,
model_view: ModelView,
Expand All @@ -131,7 +131,7 @@ def get_save_redirect_url(
# required for getting instance ID after base creation's method finished
redirect_url = str(obj.id)
else:
redirect_url = super().get_save_redirect_url(request, form, model_view, obj)
redirect_url = Admin.get_save_redirect_url(request, form, model_view, obj)

return redirect_url

Expand Down
2 changes: 1 addition & 1 deletion src/modules/admin/templates/token_details.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% extends "sqladmin/details.html" %}
{% block content %}
{% if model.raw_token != 'None' %}
{% if model.raw_token != 'None' and model.raw_token != '' %}
<div class="col-12">
<div class="card">
<div class="card-body">
Expand Down
1 change: 0 additions & 1 deletion src/modules/admin/views/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ class ReleaseAdminView(BaseModelView, model=Release):
form_columns = (
Release.version,
Release.published_at,
Release.is_active,
Release.url,
Release.notes,
)
Expand Down
2 changes: 1 addition & 1 deletion src/services/proxy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging

import httpx
import httpx2 as httpx
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse

Expand Down
4 changes: 2 additions & 2 deletions src/tests/api/test_api_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_analytics_service_called_on_request(
# Verify call arguments
call_args = mock_log_analytics.call_args
assert call_args is not None
kwargs: dict[str, Any] = call_args.kwargs # type:ignore
kwargs: dict[str, Any] = call_args.kwargs # type: ignore
background_tasks = kwargs.pop("background_tasks")
assert isinstance(background_tasks, BackgroundTasks)

Expand Down Expand Up @@ -159,7 +159,7 @@ def test_analytics_service_called_with_optional_params(
# Verify call arguments
call_args = mock_log_analytics.call_args
assert call_args is not None
kwargs: dict[str, Any] = call_args.kwargs # type:ignore
kwargs: dict[str, Any] = call_args.kwargs # type: ignore

request: ReleasesAnalyticsSchema = kwargs.pop("request")
assert isinstance(request, ReleasesAnalyticsSchema)
Expand Down
3 changes: 1 addition & 2 deletions src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from src.tests.mocks import MockAPIToken, MockUser, MockTestResponse, MockHTTPxClient


MINIMAL_ENV_VARS = {
"APP_SECRET_KEY": "test-key",
"ADMIN_PASSWORD": "test-password",
Expand All @@ -32,7 +31,7 @@ def mock_user() -> MockUser:
@pytest.fixture
def app_settings_test() -> AppSettings:
return AppSettings(
app_secret_key=SecretStr("example-UStLb8mds9K"),
app_secret_key=SecretStr("example-4MCfXpYyX1i5FRW5wkStW963MLgUzqsL"),
)


Expand Down
17 changes: 14 additions & 3 deletions src/tests/units/auth/test_auth_edge_cases.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import datetime

import jwt
import pytest
from unittest.mock import MagicMock
from pydantic import SecretStr
Expand All @@ -24,10 +26,9 @@ class TestTokenEdgeCases:
@pytest.mark.parametrize(
"secret_key",
[
pytest.param("", id="empty"),
pytest.param("a" * 1000, id="long"),
pytest.param("!@#$%^&*()_+-=[]{}|;:,.<>?`~", id="special-characters"),
pytest.param("секретный-ключ", id="unicode"),
pytest.param("__!@#$%^&*()_+-=[]{}|;:,.<>?`~__", id="special-characters"),
pytest.param("секретный-ключ-4MCfXpYyX1i5FRW5wkStW963MLgUzqsL", id="unicode"),
],
)
def test_token_with_various_secret_key(self, secret_key: str) -> None:
Expand All @@ -45,6 +46,16 @@ def test_token_with_various_secret_key(self, secret_key: str) -> None:
decoded = decode_api_token(generated.value, app_settings)
assert decoded.sub is not None

def test_token_with_empty_secret_key(self) -> None:
app_settings = AppSettings(
admin_username="test-username",
admin_password=SecretStr("test-password"),
app_secret_key=SecretStr(""),
jwt_algorithm="HS256",
)
with pytest.raises(jwt.exceptions.InvalidKeyError, match="HMAC key must not be empty."):
make_api_token(expires_at=None, settings=app_settings)

def test_token_with_minimal_expiration(self, app_settings_test: AppSettings) -> None:
minimal_exp = utcnow(skip_tz=False) + datetime.timedelta(seconds=1)
generated = make_api_token(expires_at=minimal_exp, settings=app_settings_test)
Expand Down
Loading
Loading