Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 129a1ea5f7 | |||
| c5dc537844 | |||
| 2062083f80 | |||
|
55b4089e43
|
|||
|
5355159e49
|
|||
|
a34d63aebe
|
|||
|
039d206ba0
|
|||
|
12b0eb08b4
|
|||
|
f1d2b0f6c8
|
|||
|
da37f240b1
|
|||
|
ef1d94cf68
|
|||
|
4306676699
|
@@ -1 +1,2 @@
|
||||
**/*.csv filter=lfs diff=lfs merge=lfs -text
|
||||
**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
push: true
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
target: prod
|
||||
tags: |
|
||||
gitea.le-memese.com/s3rius/zangramru:latest
|
||||
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
|
||||
|
||||
@@ -4,12 +4,6 @@ on: push
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
strategy:
|
||||
matrix:
|
||||
cmd:
|
||||
- ruff-format
|
||||
- ruff
|
||||
- mypy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -22,13 +16,11 @@ jobs:
|
||||
- name: Install deps
|
||||
run: uv sync
|
||||
- name: Run lint check
|
||||
run: uv run pre-commit run -a ${{ matrix.cmd }}
|
||||
run: uv run pre-commit run -a
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create .env
|
||||
run: touch .env
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -38,4 +30,7 @@ jobs:
|
||||
with:
|
||||
version: "2.28.0"
|
||||
- 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
|
||||
|
||||
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.web.application import get_app
|
||||
from zangramru.web.lifespan import lifespan_setup
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -73,14 +74,15 @@ async def setup_db() -> AsyncGenerator[None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fastapi_app() -> FastAPI:
|
||||
async def fastapi_app() -> AsyncGenerator[FastAPI, None]:
|
||||
"""
|
||||
Fixture for creating FastAPI app.
|
||||
|
||||
:return: fastapi app with mocked dependencies.
|
||||
"""
|
||||
application = get_app()
|
||||
return application # noqa: RET504
|
||||
async with lifespan_setup(application):
|
||||
yield application
|
||||
|
||||
|
||||
@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_USER: zangram
|
||||
ZANGRAMRU_DB_BASE: zangram
|
||||
ZANGRAMRU_START_DATE: "2026-07-01"
|
||||
|
||||
probes:
|
||||
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
|
||||
|
||||
|
||||
class Puzzle(Table):
|
||||
"""Table for puzzles."""
|
||||
|
||||
id = UUID(primary_key=True)
|
||||
id = UUID(primary_key=True, default=uuid.uuid4)
|
||||
board = Varchar(length=16)
|
||||
solutions = JSON()
|
||||
extra = 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 datetime
|
||||
import secrets
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Annotated
|
||||
@@ -107,7 +109,7 @@ class PuzzleGenerator:
|
||||
with dict_path.open("r") as f:
|
||||
rows = csv.DictReader(f, delimiter="|")
|
||||
for row in rows:
|
||||
word = row["word"]
|
||||
word = row["word"].lower().replace("ё", "е") # noqa: RUF001
|
||||
self.words[word] = row["def"]
|
||||
self.trie.add(word)
|
||||
|
||||
@@ -139,7 +141,10 @@ class PuzzleGenerator:
|
||||
while paths:
|
||||
path, trie_node = paths.pop()
|
||||
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 (
|
||||
(1, 1),
|
||||
(-1, -1),
|
||||
@@ -234,6 +239,7 @@ class PuzzleGenerator:
|
||||
prev = path[0]
|
||||
for tile in path[1:]:
|
||||
neigbours[prev].add(tile)
|
||||
neigbours[tile].add(prev)
|
||||
prev = tile
|
||||
|
||||
extras = {w: p for w, p in solutions.items() if w not in cover}
|
||||
@@ -263,20 +269,24 @@ class PuzzleGenerator:
|
||||
|
||||
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.
|
||||
|
||||
:param target: minimum number of words required to solve the puzzle.
|
||||
:param max_attempts: Since sometimes we might fail to generate a correct
|
||||
puzzle. We try it multiple times, this parameters limits how
|
||||
many attempts we should have before giving up.
|
||||
:param timeout: since puzzles can fail to generate sometimes,
|
||||
we use timeout to not let the puzzle generator hung forever!
|
||||
:return: puzzle.
|
||||
"""
|
||||
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
||||
valid_points: set[tuple[int, int]] = set()
|
||||
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:
|
||||
valid_points = set()
|
||||
|
||||
@@ -301,8 +311,6 @@ class PuzzleGenerator:
|
||||
word_defs=word_defs,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def setup_generator(app: FastAPI) -> None:
|
||||
"""Create puzzle-generator."""
|
||||
@@ -319,7 +327,7 @@ PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = PuzzleGenerator(CWD / "dict.csv")
|
||||
res = generator.generate(target=10, max_attempts=200)
|
||||
res = generator.generate(target=10)
|
||||
if res is None:
|
||||
print("No solution found!") # noqa: T201
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import enum
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
|
||||
db_base: str = "zangramru"
|
||||
db_echo: bool = False
|
||||
|
||||
# When first daily puzzle was generated.
|
||||
start_date: date = date(2026, 7, 1)
|
||||
|
||||
@property
|
||||
def db_url(self) -> URL:
|
||||
"""
|
||||
|
||||
+923
-488
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]]]
|
||||
extra: dict[str, list[tuple[int, int]]]
|
||||
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.settings import settings
|
||||
|
||||
from .schema import PuzzleOutputDTO
|
||||
from .schema import DailyPuzzleDTO, PuzzleOutputDTO
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,5 +17,55 @@ router = APIRouter()
|
||||
@router.get("/random")
|
||||
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
|
||||
"""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)
|
||||
|
||||
|
||||
@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