Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dc537844 | |||
| 2062083f80 | |||
|
55b4089e43
|
|||
|
5355159e49
|
|||
|
a34d63aebe
|
|||
|
039d206ba0
|
|||
|
12b0eb08b4
|
|||
|
f1d2b0f6c8
|
|||
|
da37f240b1
|
|||
|
ef1d94cf68
|
|||
|
4306676699
|
@@ -1 +1,2 @@
|
|||||||
**/*.csv filter=lfs diff=lfs merge=lfs -text
|
**/*.csv filter=lfs diff=lfs merge=lfs -text
|
||||||
|
**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
target: prod
|
||||||
tags: |
|
tags: |
|
||||||
gitea.le-memese.com/s3rius/zangramru:latest
|
gitea.le-memese.com/s3rius/zangramru:latest
|
||||||
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
|
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
cmd:
|
|
||||||
- ruff-format
|
|
||||||
- ruff
|
|
||||||
- mypy
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -22,13 +16,11 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: uv sync
|
run: uv sync
|
||||||
- name: Run lint check
|
- name: Run lint check
|
||||||
run: uv run pre-commit run -a ${{ matrix.cmd }}
|
run: uv run pre-commit run -a
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Create .env
|
|
||||||
run: touch .env
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
@@ -38,4 +30,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: "2.28.0"
|
version: "2.28.0"
|
||||||
- name: run tests
|
- name: run tests
|
||||||
run: docker-compose run --rm api pytest -vv
|
run: docker compose run --build --rm api pytest -vv
|
||||||
|
- name: show logs
|
||||||
|
if: 'always()'
|
||||||
|
run: docker compose logs
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
CMD ["/usr/local/bin/python", "-m", "zangramru"]
|
CMD ["/usr/local/bin/python", "-m", "zangramru"]
|
||||||
|
|
||||||
|
FROM prod AS dev
|
||||||
|
|
||||||
|
RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
|
||||||
|
--mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --locked --all-groups
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@ from piccolo.table import create_tables, drop_tables
|
|||||||
|
|
||||||
from zangramru.settings import settings
|
from zangramru.settings import settings
|
||||||
from zangramru.web.application import get_app
|
from zangramru.web.application import get_app
|
||||||
|
from zangramru.web.lifespan import lifespan_setup
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -73,14 +74,15 @@ async def setup_db() -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fastapi_app() -> FastAPI:
|
async def fastapi_app() -> AsyncGenerator[FastAPI, None]:
|
||||||
"""
|
"""
|
||||||
Fixture for creating FastAPI app.
|
Fixture for creating FastAPI app.
|
||||||
|
|
||||||
:return: fastapi app with mocked dependencies.
|
:return: fastapi app with mocked dependencies.
|
||||||
"""
|
"""
|
||||||
application = get_app()
|
application = get_app()
|
||||||
return application # noqa: RET504
|
async with lifespan_setup(application):
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from starlette import status
|
|
||||||
|
|
||||||
|
|
||||||
async def test_echo(fastapi_app: FastAPI, client: AsyncClient) -> None:
|
|
||||||
"""
|
|
||||||
Tests that echo route works.
|
|
||||||
|
|
||||||
:param fastapi_app: current application.
|
|
||||||
:param client: client for the app.
|
|
||||||
"""
|
|
||||||
url = fastapi_app.url_path_for("send_echo_message")
|
|
||||||
message = uuid.uuid4().hex
|
|
||||||
response = await client.post(url, json={"message": message})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.json()["message"] == message
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from starlette import status
|
||||||
|
|
||||||
|
from zangramru.web.api.puzzles.schema import PuzzleOutputDTO
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_random_puzzle(fastapi_app: FastAPI, client: AsyncClient) -> None:
|
||||||
|
"""
|
||||||
|
Tests that echo route works.
|
||||||
|
|
||||||
|
:param fastapi_app: current application.
|
||||||
|
:param client: client for the app.
|
||||||
|
"""
|
||||||
|
url = fastapi_app.url_path_for("random_puzzle")
|
||||||
|
response = await client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
dto = PuzzleOutputDTO.model_validate(response.json())
|
||||||
|
assert sum(len(row) for row in dto.board) == 16
|
||||||
|
assert len(dto.solutions) >= 10
|
||||||
@@ -12,6 +12,7 @@ env:
|
|||||||
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
|
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
|
||||||
ZANGRAMRU_DB_USER: zangram
|
ZANGRAMRU_DB_USER: zangram
|
||||||
ZANGRAMRU_DB_BASE: zangram
|
ZANGRAMRU_DB_BASE: zangram
|
||||||
|
ZANGRAMRU_START_DATE: "2026-07-01"
|
||||||
|
|
||||||
probes:
|
probes:
|
||||||
liveness:
|
liveness:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
|
||||||
|
from piccolo.columns.column_types import Integer
|
||||||
|
from piccolo.columns.indexes import IndexMethod
|
||||||
|
|
||||||
|
ID = "2026-07-01T21:56:16:112157"
|
||||||
|
VERSION = "1.34.0"
|
||||||
|
DESCRIPTION = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def forwards():
|
||||||
|
manager = MigrationManager(
|
||||||
|
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.drop_column(
|
||||||
|
table_class_name="Puzzle",
|
||||||
|
tablename="puzzle",
|
||||||
|
column_name="date",
|
||||||
|
db_column_name="date",
|
||||||
|
schema=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.add_column(
|
||||||
|
table_class_name="Puzzle",
|
||||||
|
tablename="puzzle",
|
||||||
|
column_name="number",
|
||||||
|
db_column_name="number",
|
||||||
|
column_class_name="Integer",
|
||||||
|
column_class=Integer,
|
||||||
|
params={
|
||||||
|
"default": 0,
|
||||||
|
"null": False,
|
||||||
|
"primary_key": False,
|
||||||
|
"unique": False,
|
||||||
|
"index": False,
|
||||||
|
"index_method": IndexMethod.btree,
|
||||||
|
"choices": None,
|
||||||
|
"db_column_name": None,
|
||||||
|
"secret": False,
|
||||||
|
},
|
||||||
|
schema=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return manager
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
from piccolo.columns import JSON, UUID, Date, Varchar
|
import uuid
|
||||||
|
|
||||||
|
from piccolo.columns import JSON, UUID, Integer, Varchar
|
||||||
from piccolo.table import Table
|
from piccolo.table import Table
|
||||||
|
|
||||||
|
|
||||||
class Puzzle(Table):
|
class Puzzle(Table):
|
||||||
"""Table for puzzles."""
|
"""Table for puzzles."""
|
||||||
|
|
||||||
id = UUID(primary_key=True)
|
id = UUID(primary_key=True, default=uuid.uuid4)
|
||||||
board = Varchar(length=16)
|
board = Varchar(length=16)
|
||||||
solutions = JSON()
|
solutions = JSON()
|
||||||
extra = JSON()
|
extra = JSON()
|
||||||
definitions = JSON()
|
definitions = JSON()
|
||||||
date = Date()
|
number = Integer()
|
||||||
|
|||||||
+4517
-17463
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
import csv
|
import csv
|
||||||
|
import datetime
|
||||||
import secrets
|
import secrets
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -107,7 +109,7 @@ class PuzzleGenerator:
|
|||||||
with dict_path.open("r") as f:
|
with dict_path.open("r") as f:
|
||||||
rows = csv.DictReader(f, delimiter="|")
|
rows = csv.DictReader(f, delimiter="|")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
word = row["word"]
|
word = row["word"].lower().replace("ё", "е") # noqa: RUF001
|
||||||
self.words[word] = row["def"]
|
self.words[word] = row["def"]
|
||||||
self.trie.add(word)
|
self.trie.add(word)
|
||||||
|
|
||||||
@@ -139,7 +141,10 @@ class PuzzleGenerator:
|
|||||||
while paths:
|
while paths:
|
||||||
path, trie_node = paths.pop()
|
path, trie_node = paths.pop()
|
||||||
if trie_node.valid_word and len(trie_node.value) >= 4:
|
if trie_node.valid_word and len(trie_node.value) >= 4:
|
||||||
solutions[trie_node.value] = path
|
word = trie_node.value
|
||||||
|
if word in solutions:
|
||||||
|
return {}
|
||||||
|
solutions[word] = path
|
||||||
for dx, dy in (
|
for dx, dy in (
|
||||||
(1, 1),
|
(1, 1),
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
@@ -234,6 +239,7 @@ class PuzzleGenerator:
|
|||||||
prev = path[0]
|
prev = path[0]
|
||||||
for tile in path[1:]:
|
for tile in path[1:]:
|
||||||
neigbours[prev].add(tile)
|
neigbours[prev].add(tile)
|
||||||
|
neigbours[tile].add(prev)
|
||||||
prev = tile
|
prev = tile
|
||||||
|
|
||||||
extras = {w: p for w, p in solutions.items() if w not in cover}
|
extras = {w: p for w, p in solutions.items() if w not in cover}
|
||||||
@@ -263,20 +269,24 @@ class PuzzleGenerator:
|
|||||||
|
|
||||||
return used_cells
|
return used_cells
|
||||||
|
|
||||||
def generate(self, target: int, max_attempts: int) -> Puzzle | None:
|
def generate(self, target: int, timeout: int = 10) -> Puzzle:
|
||||||
"""
|
"""
|
||||||
Generate a valid puzzle that has solutions.
|
Generate a valid puzzle that has solutions.
|
||||||
|
|
||||||
:param target: minimum number of words required to solve the puzzle.
|
:param target: minimum number of words required to solve the puzzle.
|
||||||
:param max_attempts: Since sometimes we might fail to generate a correct
|
:param timeout: since puzzles can fail to generate sometimes,
|
||||||
puzzle. We try it multiple times, this parameters limits how
|
we use timeout to not let the puzzle generator hung forever!
|
||||||
many attempts we should have before giving up.
|
|
||||||
:return: puzzle.
|
:return: puzzle.
|
||||||
"""
|
"""
|
||||||
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
||||||
valid_points: set[tuple[int, int]] = set()
|
valid_points: set[tuple[int, int]] = set()
|
||||||
single_board_attempt = 0
|
single_board_attempt = 0
|
||||||
for _ in range(max_attempts):
|
started = datetime.datetime.now()
|
||||||
|
while True:
|
||||||
|
if datetime.datetime.now() - started > timedelta(seconds=timeout):
|
||||||
|
raise ValueError(
|
||||||
|
f"Coud not find a valid solution in {timeout} seconds!"
|
||||||
|
)
|
||||||
if single_board_attempt > 50:
|
if single_board_attempt > 50:
|
||||||
valid_points = set()
|
valid_points = set()
|
||||||
|
|
||||||
@@ -301,8 +311,6 @@ class PuzzleGenerator:
|
|||||||
word_defs=word_defs,
|
word_defs=word_defs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def setup_generator(app: FastAPI) -> None:
|
def setup_generator(app: FastAPI) -> None:
|
||||||
"""Create puzzle-generator."""
|
"""Create puzzle-generator."""
|
||||||
@@ -319,7 +327,7 @@ PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
generator = PuzzleGenerator(CWD / "dict.csv")
|
generator = PuzzleGenerator(CWD / "dict.csv")
|
||||||
res = generator.generate(target=10, max_attempts=200)
|
res = generator.generate(target=10)
|
||||||
if res is None:
|
if res is None:
|
||||||
print("No solution found!") # noqa: T201
|
print("No solution found!") # noqa: T201
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
|
|||||||
db_base: str = "zangramru"
|
db_base: str = "zangramru"
|
||||||
db_echo: bool = False
|
db_echo: bool = False
|
||||||
|
|
||||||
|
# When first daily puzzle was generated.
|
||||||
|
start_date: date = date(2026, 7, 1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db_url(self) -> URL:
|
def db_url(self) -> URL:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+917
-482
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -8,3 +8,9 @@ class PuzzleOutputDTO(BaseModel):
|
|||||||
solutions: dict[str, list[tuple[int, int]]]
|
solutions: dict[str, list[tuple[int, int]]]
|
||||||
extra: dict[str, list[tuple[int, int]]]
|
extra: dict[str, list[tuple[int, int]]]
|
||||||
word_defs: dict[str, str]
|
word_defs: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class DailyPuzzleDTO(PuzzleOutputDTO):
|
||||||
|
"""Daily puzzles DTO."""
|
||||||
|
|
||||||
|
number: int
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
from fastapi import APIRouter
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import PositiveInt
|
||||||
|
from starlette import status
|
||||||
|
|
||||||
|
from zangramru.db.models.puzzle import Puzzle
|
||||||
from zangramru.services.puzzles import PuzzleGeneratorDep
|
from zangramru.services.puzzles import PuzzleGeneratorDep
|
||||||
|
from zangramru.settings import settings
|
||||||
|
|
||||||
from .schema import PuzzleOutputDTO
|
from .schema import DailyPuzzleDTO, PuzzleOutputDTO
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,5 +17,55 @@ router = APIRouter()
|
|||||||
@router.get("/random")
|
@router.get("/random")
|
||||||
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
|
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
|
||||||
"""Generate a random puzzle."""
|
"""Generate a random puzzle."""
|
||||||
puzzle = generator.generate(target=10, max_attempts=500)
|
puzzle = generator.generate(target=10)
|
||||||
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
|
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily")
|
||||||
|
async def daily_puzzle(
|
||||||
|
generator: PuzzleGeneratorDep,
|
||||||
|
number: PositiveInt | None = None,
|
||||||
|
) -> DailyPuzzleDTO:
|
||||||
|
"""Generate a random puzzle."""
|
||||||
|
# Because they start at one, so we need to subtract one.
|
||||||
|
target_date = date.today()
|
||||||
|
if number is not None:
|
||||||
|
target_date = settings.start_date + timedelta(days=number - 1)
|
||||||
|
|
||||||
|
if target_date > date.today():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Doesn't exist",
|
||||||
|
)
|
||||||
|
|
||||||
|
number = (target_date - settings.start_date).days
|
||||||
|
|
||||||
|
daily_puzzle = await Puzzle.objects().where(Puzzle.number == number).first()
|
||||||
|
|
||||||
|
if daily_puzzle is not None:
|
||||||
|
board = [list(daily_puzzle.board[i * 4 : i * 4 + 4]) for i in range(4)]
|
||||||
|
return DailyPuzzleDTO(
|
||||||
|
number=(date.today() - settings.start_date).days,
|
||||||
|
board=board,
|
||||||
|
solutions=json.loads(daily_puzzle.solutions),
|
||||||
|
extra=json.loads(daily_puzzle.extra),
|
||||||
|
word_defs=json.loads(daily_puzzle.definitions),
|
||||||
|
)
|
||||||
|
puzzle = generator.generate(target=10)
|
||||||
|
compressedboard = "".join("".join(row) for row in puzzle.board)
|
||||||
|
await Puzzle(
|
||||||
|
{
|
||||||
|
Puzzle.board: compressedboard,
|
||||||
|
Puzzle.number: number,
|
||||||
|
Puzzle.solutions: json.dumps(puzzle.solutions),
|
||||||
|
Puzzle.definitions: json.dumps(puzzle.word_defs),
|
||||||
|
Puzzle.extra: json.dumps(puzzle.extra),
|
||||||
|
}
|
||||||
|
).save()
|
||||||
|
return DailyPuzzleDTO(
|
||||||
|
number=number,
|
||||||
|
board=puzzle.board,
|
||||||
|
solutions=puzzle.solutions,
|
||||||
|
extra=puzzle.extra,
|
||||||
|
word_defs=puzzle.word_defs,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user