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
7 changes: 6 additions & 1 deletion src/rowbridge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""RowBridge package."""

__version__ = "0.5.0"
from importlib.metadata import PackageNotFoundError, version

try:
__version__ = version("rowbridge")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
8 changes: 2 additions & 6 deletions src/rowbridge/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,13 @@ class _RuleResult:


def build_rules(mapping: FieldMapping, settings: MatchSettings) -> tuple[ComparisonRule, ...]:
has_secondary = bool(mapping.secondary_a and mapping.secondary_b)
primary_weight = 0.55 if has_secondary else 0.70
amount_weight = 0.15 if has_secondary else 0.20

rules = [
ComparisonRule(
field="primary",
column_a=mapping.primary_a,
column_b=mapping.primary_b,
kind=RuleKind.FUZZY_TEXT,
weight=primary_weight,
weight=0.55,
)
]
if mapping.secondary_a and mapping.secondary_b:
Expand All @@ -61,7 +57,7 @@ def build_rules(mapping: FieldMapping, settings: MatchSettings) -> tuple[Compari
column_a=mapping.amount_a,
column_b=mapping.amount_b,
kind=RuleKind.NUMERIC_TOLERANCE,
weight=amount_weight,
weight=0.15,
tolerance=settings.amount_tolerance,
)
)
Expand Down
70 changes: 61 additions & 9 deletions src/rowbridge/matching_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,72 @@
from datetime import date, datetime
from decimal import Decimal, InvalidOperation

_NON_ALNUM = re.compile(r"[^a-z0-9]+")
_DATE_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d.%m.%Y")


def normalize_text(value: str) -> str:
decomposed = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
return _NON_ALNUM.sub("", decomposed.casefold())
normalized = unicodedata.normalize("NFKC", value).casefold()
return "".join(character for character in normalized if character.isalnum())


def _valid_grouping(value: str, separator: str) -> bool:
unsigned = value.lstrip("+-")
groups = unsigned.split(separator)
return (
len(groups) > 1
and groups[0].isdigit()
and 1 <= len(groups[0]) <= 3
and all(group.isdigit() and len(group) == 3 for group in groups[1:])
)


def parse_amount(value: str) -> Decimal | None:
cleaned = value.strip().replace(" ", "")
if not cleaned:
stripped = value.strip()
if not stripped:
return None
if any(character.isalpha() for character in cleaned):
if any(character.isalpha() for character in stripped):
return None
if cleaned.count(",") == 1 and "." not in cleaned:

parenthesized_negative = stripped.startswith("(") and stripped.endswith(")")
if ("(" in stripped or ")" in stripped) and not parenthesized_negative:
return None
if parenthesized_negative:
stripped = stripped[1:-1].strip()

cleaned = stripped.replace(" ", "")
cleaned = re.sub(r"[^0-9,.\-]", "", cleaned)
comma_count = cleaned.count(",")
dot_count = cleaned.count(".")

if comma_count and dot_count:
decimal_separator = "," if cleaned.rfind(",") > cleaned.rfind(".") else "."
grouping_separator = "." if decimal_separator == "," else ","
integer_part, fractional_part = cleaned.rsplit(decimal_separator, 1)

if decimal_separator in integer_part or grouping_separator in fractional_part:
return None
if grouping_separator in integer_part:
if not _valid_grouping(integer_part, grouping_separator):
return None
integer_part = integer_part.replace(grouping_separator, "")

cleaned = f"{integer_part}.{fractional_part}"
elif comma_count == 1:
cleaned = cleaned.replace(",", ".")
else:
elif comma_count > 1:
if not _valid_grouping(cleaned, ","):
return None
cleaned = cleaned.replace(",", "")
cleaned = re.sub(r"[^0-9.\-]", "", cleaned)
elif dot_count > 1:
if not _valid_grouping(cleaned, "."):
return None
cleaned = cleaned.replace(".", "")

if parenthesized_negative:
if cleaned.startswith("-"):
return None
cleaned = f"-{cleaned}"

try:
return Decimal(cleaned)
except (InvalidOperation, ValueError):
Expand All @@ -35,6 +81,12 @@ def parse_date(value: str) -> date | None:
stripped = value.strip()
if not stripped:
return None

try:
return datetime.fromisoformat(stripped).date()
except ValueError:
pass

for format_string in _DATE_FORMATS:
try:
return datetime.strptime(stripped, format_string).date()
Expand Down
3 changes: 2 additions & 1 deletion src/rowbridge/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from starlette.middleware.base import RequestResponseEndpoint
from starlette.middleware.trustedhost import TrustedHostMiddleware

from rowbridge import __version__
from rowbridge.config import Settings
from rowbridge.exports import build_reconciliation_csv, build_reconciliation_xlsx
from rowbridge.ingestion import (
Expand Down Expand Up @@ -126,7 +127,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
repository.initialize()
templates = Jinja2Templates(directory=PACKAGE_DIR / "templates")

app = FastAPI(title="RowBridge", version="0.6.0")
app = FastAPI(title="RowBridge", version=__version__)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["127.0.0.1", "localhost", "testserver"],
Expand Down
146 changes: 145 additions & 1 deletion tests/test_matching.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from decimal import Decimal

from rowbridge.candidates import generate_candidates
from rowbridge.matching import (
build_rules,
reconcile,
reconcile_with_diagnostics,
score_pair,
)
from rowbridge.matching_utils import normalize_text
from rowbridge.matching_utils import normalize_text, parse_amount
from rowbridge.models import CsvTable, FieldMapping, MatchSettings, MatchStatus, RuleKind


Expand All @@ -14,6 +16,110 @@ def test_normalize_text_removes_case_spacing_and_punctuation() -> None:
assert normalize_text("ACME, Ltd.") == "acmeltd"


def test_normalize_text_preserves_unicode_letters() -> None:
assert normalize_text(
"\u0422\u041e\u0412 \u00ab\u041a\u0438\u0457\u0432-2026\u00bb"
) == "\u0442\u043e\u0432\u043a\u0438\u0457\u04322026"
assert normalize_text(
"Caf\u00e9, S\u00e3o Paulo!"
) == "caf\u00e9s\u00e3opaulo"
assert normalize_text("Cafe\u0301") == "caf\u00e9"


def test_reconcile_matches_cyrillic_primary_values() -> None:
table_a = CsvTable(
filename="a.csv",
headers=("name",),
rows=(
{
"name": (
"\u0422\u041e\u0412 "
"\u00ab\u0420\u043e\u043c\u0430\u0448\u043a\u0430\u00bb"
)
},
),
)
table_b = CsvTable(
filename="b.csv",
headers=("name",),
rows=(
{
"name": (
"\u0442\u043e\u0432 "
"\u0440\u043e\u043c\u0430\u0448\u043a\u0430"
)
},
),
)

decisions = reconcile(
table_a,
table_b,
FieldMapping(primary_a="name", primary_b="name"),
MatchSettings(),
)

assert len(decisions) == 1
assert decisions[0].status == MatchStatus.AUTO_MATCHED
assert decisions[0].score == 1.0


def test_parse_amount_supports_common_decimal_and_grouping_formats() -> None:
assert parse_amount("1234.56") == Decimal("1234.56")
assert parse_amount("1234,56") == Decimal("1234.56")
assert parse_amount("1,234.56") == Decimal("1234.56")
assert parse_amount("1.234,56") == Decimal("1234.56")
assert parse_amount("12,345,678.90") == Decimal("12345678.90")
assert parse_amount("12.345.678,90") == Decimal("12345678.90")


def test_parse_amount_preserves_accounting_parentheses_as_negative() -> None:
assert parse_amount("(1,234.56)") == Decimal("-1234.56")
assert parse_amount("(1.234,56)") == Decimal("-1234.56")
assert parse_amount("($1,234.56)") == Decimal("-1234.56")
assert parse_amount("-(1,234.56)") is None


def test_score_pair_matches_amounts_with_different_locale_separators() -> None:
mapping = FieldMapping(
primary_a="ref",
primary_b="reference",
amount_a="amount",
amount_b="total",
)

score, evidence = score_pair(
{"ref": "INV-001", "amount": "1.234,56"},
{"reference": "INV001", "total": "1,234.56"},
mapping,
MatchSettings(),
)

assert score == 1.0
amount_evidence = next(item for item in evidence if item.field == "amount")
assert "difference 0.00" in amount_evidence.detail


def test_score_pair_accepts_excel_datetime_text_for_date_rule() -> None:
mapping = FieldMapping(
primary_a="ref",
primary_b="reference",
date_a="date",
date_b="paid_at",
)

score, evidence = score_pair(
{"ref": "INV-001", "date": "2026-10-02"},
{"reference": "INV001", "paid_at": "2026-10-02 00:00:00"},
mapping,
MatchSettings(),
)

assert score == 1.0
date_evidence = next(item for item in evidence if item.field == "date")
assert "difference 0 day(s)" in date_evidence.detail


def test_build_rules_uses_typed_comparators_and_expected_weights() -> None:
mapping = FieldMapping(
primary_a="ref",
Expand All @@ -36,6 +142,44 @@ def test_build_rules_uses_typed_comparators_and_expected_weights() -> None:
assert sum(rule.weight for rule in rules) == 1.0


def test_optional_rules_keep_base_weights_before_normalization() -> None:
mapping = FieldMapping(
primary_a="ref",
primary_b="reference",
amount_a="amount",
amount_b="total",
date_a="date",
date_b="paid",
)

rules = build_rules(mapping, MatchSettings())

assert [(rule.field, rule.weight) for rule in rules] == [
("primary", 0.55),
("amount", 0.15),
("date", 0.10),
]


def test_score_pair_normalizes_remaining_rule_weights() -> None:
mapping = FieldMapping(
primary_a="ref",
primary_b="reference",
amount_a="amount",
amount_b="total",
)

score, evidence = score_pair(
{"ref": "INV-001", "amount": "10.00"},
{"reference": "INV001", "total": "11.00"},
mapping,
MatchSettings(),
)

assert score == 0.785714
assert [item.score for item in evidence] == [0.785714, 0.0]


def test_score_pair_uses_primary_secondary_amount_and_date_evidence() -> None:
mapping = FieldMapping(
primary_a="ref",
Expand Down
16 changes: 16 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

import tomllib
from pathlib import Path

from rowbridge import __version__

PROJECT_ROOT = Path(__file__).resolve().parents[1]


def test_runtime_version_matches_project_version() -> None:
pyproject = tomllib.loads(
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")
)

assert __version__ == pyproject["project"]["version"]
Loading