Skip to content
Closed
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
5 changes: 3 additions & 2 deletions docs/REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ project that knows openpyxl; `export/vba/` holds the Windows-only macro buttons,
`MacroExporter`, so the CLI and the UI pick a backend with `select_exporter()` instead of
each branching on `supports_excel_macros()` themselves.

A `Grid` is a frozen dataclass of rows plus layout — column widths by index, the header
A `Grid` is a frozen Pydantic model of rows plus layout — column widths by index, the header
row, the difference columns, the two conditional-format colours — which is what makes the
report assertable as data. `tests/fixtures/golden_grids.json` pins all three sheets;
a diff there is the diff a user would see in Excel.
Expand Down Expand Up @@ -533,7 +533,8 @@ Everything was moved to its latest release, which surfaced three problems worth
4. **Python 3.14** is the target runtime (added 2026-08-18). This forced an `lxml` floor of 6.0.1,
the first release shipping cp314 wheels.
5. **pydantic and pydantic-settings** are adopted for configuration (added 2026-08-18), which
pulled phase 4 forward ahead of phase 3.
pulled phase 4 forward ahead of phase 3. As of 2026-08-19, every structured production and
test model uses Pydantic; the project no longer uses `dataclasses`.
6. **The interface stays Russian-only.** No message catalogue, no localisation layer: the sheet
labels stay as Russian literals in `export/grids.py`, where they read as the report's own
wording rather than as keys pointing somewhere else.
Expand Down
18 changes: 11 additions & 7 deletions src/pharmparser/domain/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from statistics import mean

from pydantic import BaseModel, ConfigDict

from .models import Pharmacy, PriceTable

DifferenceFn = Callable[[float, float], float]
Expand All @@ -32,8 +33,7 @@ def percentage_difference(reference: float, other: float) -> float:
return (other - reference) / reference * 100


@dataclass(frozen=True, slots=True)
class ComparisonRow:
class ComparisonRow(BaseModel):
"""One item across every pharmacy.

``prices`` is parallel to ``PriceTable.pharmacies``; ``differences`` is parallel
Expand All @@ -42,13 +42,16 @@ class ComparisonRow:
(B9: the old code wrote 0 for both).
"""

model_config = ConfigDict(frozen=True, extra="forbid")

item: str
prices: tuple[float | None, ...]
differences: tuple[float | None, ...]


@dataclass(frozen=True, slots=True)
class CompetitorStats:
class CompetitorStats(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")

pharmacy: Pharmacy
assortment: int
shared: int
Expand All @@ -68,8 +71,9 @@ class CompetitorStats:
"""Mean competitor minus reference price for shared items, in percent."""


@dataclass(frozen=True, slots=True)
class MarketSummary:
class MarketSummary(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")

reference: Pharmacy
assortment: int
market_assortment: int
Expand Down
24 changes: 15 additions & 9 deletions src/pharmparser/domain/models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
"""Core domain model.

Deliberately free of I/O and of every framework the app uses: no openpyxl, no
customtkinter, no COM, no network. Everything here is directly unit-testable.
Deliberately free of I/O and presentation concerns: no openpyxl, no customtkinter,
no COM, no network. Everything here is directly unit-testable.
"""

from __future__ import annotations

from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from typing import Self

from pydantic import BaseModel, ConfigDict, model_validator

@dataclass(frozen=True, slots=True)
class Pharmacy:

class Pharmacy(BaseModel):
"""A pharmacy whose prices are being compared.

``id`` is the stable identity (the numeric id from the tabletka.by URL) and is
Expand All @@ -20,23 +21,27 @@ class Pharmacy:
conflating the two misaligned the exported sheet.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

id: str
name: str


@dataclass(frozen=True, slots=True)
class PriceTable:
class PriceTable(BaseModel):
"""Prices for a set of pharmacies.

The first pharmacy is the *reference*: every comparison in
:mod:`pharmparser.domain.analysis` is expressed relative to it.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

pharmacies: tuple[Pharmacy, ...]
prices: Mapping[str, Mapping[str, float]]
"""Pharmacy id -> item name -> price."""

def __post_init__(self) -> None:
@model_validator(mode="after")
def _validate_pharmacy_ids(self) -> Self:
if not self.pharmacies:
raise ValueError("a price table needs at least one pharmacy")
ids = [pharmacy.id for pharmacy in self.pharmacies]
Expand All @@ -46,6 +51,7 @@ def __post_init__(self) -> None:
missing = [pid for pid in ids if pid not in self.prices]
if missing:
raise ValueError(f"no prices supplied for pharmacy ids: {missing}")
return self

@classmethod
def build(cls, entries: Iterable[tuple[Pharmacy, Mapping[str, float]]]) -> PriceTable:
Expand All @@ -54,7 +60,7 @@ def build(cls, entries: Iterable[tuple[Pharmacy, Mapping[str, float]]]) -> Price
for pharmacy, item_prices in entries:
pharmacies.append(pharmacy)
prices[pharmacy.id] = dict(item_prices)
return cls(tuple(pharmacies), prices)
return cls(pharmacies=tuple(pharmacies), prices=prices)

@classmethod
def from_mapping(cls, names: Sequence[str], data: Mapping[str, Mapping[str, float]]) -> PriceTable:
Expand Down
5 changes: 4 additions & 1 deletion src/pharmparser/export/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def _package_macros(plain: Path, built: Path, grids, sheets) -> Path:
titles = [grid.title for grid in grids]
project = build_project({MODULE_NAME: module_source(sheets)}, titles)
specs = {
title: [ButtonSpec(button.cell_address, button.caption, button.macro.name) for button in buttons]
title: [
ButtonSpec(cell=button.cell_address, caption=button.caption, macro=button.macro.name)
for button in buttons
]
for title, buttons in sheets.items()
}
return package(plain, built, project, specs, titles)
Expand Down
17 changes: 10 additions & 7 deletions src/pharmparser/export/grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field

from pydantic import BaseModel, ConfigDict, Field

from ..config import DATA_SHEET, PERCENT_SHEET, ExportSettings
from ..domain import DifferenceFn, PriceTable, absolute_difference, comparison_rows, percentage_difference, summarise

Cell = str | float | None
Cell = str | int | float | None
"""One cell's value. ``None`` is written as a genuinely empty cell."""

HEADER_OFFSET = 2
Expand All @@ -41,10 +42,11 @@
"""Columns used by the redesigned analysis dashboard."""


@dataclass(frozen=True, slots=True)
class AnalysisPresentation:
class AnalysisPresentation(BaseModel):
"""Semantic layout metadata for the styled analysis dashboard."""

model_config = ConfigDict(frozen=True, extra="forbid")

merged_ranges: tuple[tuple[int, int, int, int], ...]
section_rows: tuple[int, ...]
metric_rows: tuple[int, ...]
Expand All @@ -54,15 +56,16 @@ class AnalysisPresentation:
note_rows: tuple[int, ...]


@dataclass(frozen=True, slots=True)
class Grid:
class Grid(BaseModel):
"""A whole sheet: its cells plus how they should be laid out."""

model_config = ConfigDict(frozen=True, extra="forbid")

title: str
rows: tuple[tuple[Cell, ...], ...]
width: int
"""Number of columns actually carrying content."""
column_widths: Mapping[int, float] = field(default_factory=dict)
column_widths: Mapping[int, float] = Field(default_factory=dict)
"""Explicit widths by 1-based column index; every other column gets the default."""
default_column_width: float = 15
header_row: int | None = None
Expand Down
8 changes: 5 additions & 3 deletions src/pharmparser/export/vba/xlsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
import shutil
import zipfile
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path

from pydantic import BaseModel, ConfigDict

logger = logging.getLogger(__name__)

CONTENT_TYPES = "[Content_Types].xml"
Expand All @@ -39,10 +40,11 @@
_CELL = re.compile(r"^([A-Z]+)(\d+)$")


@dataclass(frozen=True, slots=True)
class ButtonSpec:
class ButtonSpec(BaseModel):
"""A form-control button anchored at ``cell`` that runs ``macro`` when clicked."""

model_config = ConfigDict(frozen=True, extra="forbid")

cell: str
caption: str
macro: str
Expand Down
7 changes: 4 additions & 3 deletions src/pharmparser/scraping/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass

from lxml import html as lxml_html
from lxml.etree import XPath, _Element
from pydantic import BaseModel, ConfigDict

logger = logging.getLogger(__name__)

Expand All @@ -56,8 +56,9 @@ def _has_class(name: str) -> str:
_NUMBER = re.compile(r"-?\d+(?:[.,]\d+)?")


@dataclass(frozen=True, slots=True)
class DrugPrice:
class DrugPrice(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")

name: str
price: float

Expand Down
39 changes: 38 additions & 1 deletion src/pharmparser/ui/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,69 @@

from __future__ import annotations

from collections.abc import Callable
from typing import Any

from customtkinter import CTkButton

from .widgets import create_custom_entry

DELETE_CONFIRMATION_MS = 5_000


class Entry:
def __init__(
self,
parent: Any,
on_delete: Callable[[Entry], None],
text_placeholder: str = "Pharmacy Name",
url_placeholder: str = "https://tabletka.by/pharmacies/****",
initial_text: str = "",
initial_url: str = "",
) -> None:
self.parent = parent
self.on_delete = on_delete
self.delete_confirmation_id: str | None = None
self.text = create_custom_entry(parent, text_placeholder, initial_text)
self.url = create_custom_entry(parent, url_placeholder, initial_url)
self.delete_button = CTkButton(parent, text="✕", width=32, command=self.request_delete)

def grid(
self, text_row: int, url_row: int, column: int, padx: Any, pady: Any, sticky: str
) -> None:
self.text.grid(row=text_row, column=column, padx=padx, pady=pady, sticky=sticky)
self.url.grid(row=url_row, column=column + 1, padx=padx, pady=pady, sticky=sticky)
self.delete_button.grid(row=text_row, column=column + 2, padx=(5, 0), pady=pady)

def destroy(self) -> None:
def hide(self) -> None:
self.text.grid_forget()
self.url.grid_forget()
self.delete_button.grid_forget()

def request_delete(self) -> None:
"""Require a second click within five seconds before deleting the row."""
if self.delete_confirmation_id is None:
self.delete_button.configure(text="✓")
self.delete_confirmation_id = self.parent.after(
DELETE_CONFIRMATION_MS, self.reset_delete_confirmation
)
return

self.parent.after_cancel(self.delete_confirmation_id)
self.delete_confirmation_id = None
self.on_delete(self)

def reset_delete_confirmation(self) -> None:
self.delete_confirmation_id = None
self.delete_button.configure(text="✕")

def destroy(self) -> None:
if self.delete_confirmation_id is not None:
self.parent.after_cancel(self.delete_confirmation_id)
self.delete_confirmation_id = None
self.text.destroy()
self.url.destroy()
self.delete_button.destroy()

def get_text(self) -> str:
return self.text.get()
Expand Down
21 changes: 14 additions & 7 deletions src/pharmparser/ui/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def __init__(self, parent: Any, config: ProfileConfig) -> None:
self.parent = parent
self.name = config.name
self.entries = [
Entry(parent, initial_text=entry.name, initial_url=entry.url) for entry in config.pharmacies
Entry(parent, self.delete_entry, initial_text=entry.name, initial_url=entry.url)
for entry in config.pharmacies
]

def to_config(self) -> ProfileConfig:
Expand All @@ -28,17 +29,23 @@ def to_config(self) -> ProfileConfig:

def hide(self) -> None:
for entry in self.entries:
entry.destroy()
entry.hide()

def display(self) -> None:
for i, entry in enumerate(self.entries):
entry.grid(text_row=i + 2, url_row=i + 2, column=0, padx=(5, 0), pady=(5, 5), sticky="nsew")

def add_entry(self) -> None:
self.entries.append(Entry(self.parent))
self.entries.append(Entry(self.parent, self.delete_entry))
self.display()

def delete_entry(self) -> None:
if self.entries:
self.entries.pop().destroy()
self.display()
def delete_entry(self, entry: Entry | None = None) -> None:
if not self.entries:
return

target = entry or self.entries[-1]
if target not in self.entries:
return
self.entries.remove(target)
target.destroy()
self.display()
8 changes: 5 additions & 3 deletions src/pharmparser/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@
import sys
import tempfile
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse

from pydantic import BaseModel, ConfigDict

from . import __version__

logger = logging.getLogger(__name__)
Expand All @@ -57,10 +58,11 @@ class UpdateError(RuntimeError):
"""An update could not be checked for, downloaded, or installed."""


@dataclass(frozen=True, slots=True)
class Release:
class Release(BaseModel):
"""A published release and the binary it offers for this platform."""

model_config = ConfigDict(frozen=True, extra="forbid")

version: str
tag: str
page_url: str
Expand Down
Loading