Skip to content

Commit 1a7bff9

Browse files
authored
feat(analytics): add local validation analytics and CLI (#12)
This adds an optional, local-only analytics feature to track postal code validation stats. - Implements API functions: record_validation, show_stats, and reset_stats. - Adds a CLI command 'postal-regex stats' to display a dashboard. - Integrates stat recording directly into the core validate() function. - All data is stored locally in ~/.postalregex_stats.json.
1 parent a449079 commit 1a7bff9

6 files changed

Lines changed: 285 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ dependencies = [
1616
"regex==2025.9.18"
1717
]
1818

19+
[project.scripts]
20+
postal-regex = "postal_regex.cli:main"
21+
1922
[tool.setuptools]
2023
packages = ["postal_regex"]
2124
package-dir = {"" = "src"}
@@ -34,4 +37,4 @@ update_changelog_on_bump = true
3437
pandas = ["pandas>=2.0", "pyarrow>=11.0"]
3538
spark = ["pyspark>=4.0"]
3639
dask = ["dask[complete]>=2025.9.0"]
37-
dataframe = ["pandas>=2.0", "pyarrow>=11.0", "dask[complete]>=2025.9.0"]
40+
dataframe = ["pandas>=2.0", "pyarrow>=11.0", "dask[complete]>=2025.9.0"]

src/postal_regex/__init__.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Core validation and lookup functions
2+
from .core import (
3+
validate,
4+
normalize,
5+
get_entry,
6+
get_country_regex,
7+
get_supported_countries,
8+
)
9+
10+
# Bulk processing utilities for DataFrames and iterables
11+
from .bulk import (
12+
bulk_validate,
13+
bulk_normalize,
14+
validate_dataframe,
15+
validate_spark_dataframe,
16+
load_json,
17+
load_pandas,
18+
load_spark,
19+
export_csv,
20+
export_parquet,
21+
)
22+
23+
# Local analytics and statistics functions
24+
from .analytics import (
25+
record_validation,
26+
show_stats,
27+
reset_stats,
28+
get_stats,
29+
)
30+
31+
__all__ = [
32+
# Core
33+
"validate",
34+
"normalize",
35+
"get_entry",
36+
"get_country_regex",
37+
"get_supported_countries",
38+
# Bulk
39+
"bulk_validate",
40+
"bulk_normalize",
41+
"validate_dataframe",
42+
"validate_spark_dataframe",
43+
"load_json",
44+
"load_pandas",
45+
"load_spark",
46+
"export_csv",
47+
"export_parquet",
48+
# Analytics
49+
"record_validation",
50+
"show_stats",
51+
"reset_stats",
52+
"get_stats"
53+
]

src/postal_regex/analytics.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
from pathlib import Path
3+
4+
STATS_FILE = Path.home() / ".postalregex_stats.json"
5+
6+
def _load_stats():
7+
"""Load statistics from the local JSON file."""
8+
if not STATS_FILE.exists():
9+
return {}
10+
with open(STATS_FILE, "r") as f:
11+
try:
12+
return json.load(f)
13+
except json.JSONDecodeError:
14+
return {}
15+
16+
def _save_stats(stats):
17+
"""Save statistics to the local JSON file."""
18+
with open(STATS_FILE, "w") as f:
19+
json.dump(stats, f, indent=2)
20+
21+
def record_validation(country_code, is_valid):
22+
"""
23+
Record a validation attempt for a given country.
24+
25+
Args:
26+
country_code (str): The ISO 3166-1 alpha-2 country code.
27+
is_valid (bool): True if the validation was successful, False otherwise.
28+
"""
29+
stats = _load_stats()
30+
31+
# Ensure the country entry exists
32+
if country_code not in stats:
33+
stats[country_code] = {"valid": 0, "invalid": 0}
34+
35+
# Increment the appropriate counter
36+
if is_valid:
37+
stats[country_code]["valid"] += 1
38+
else:
39+
stats[country_code]["invalid"] += 1
40+
_save_stats(stats)
41+
42+
def reset_stats():
43+
"""Clear all recorded statistics."""
44+
if STATS_FILE.exists():
45+
STATS_FILE.unlink()
46+
print("Local validation statistics have been reset.")
47+
else:
48+
print("No statistics file found to reset.")
49+
50+
def get_stats() -> dict:
51+
"""
52+
Loads and returns the validation statistics from the stats file.
53+
This function only retrieves data and does not print anything.
54+
55+
Returns:
56+
dict: A dictionary containing the validation stats, or an empty dict if none exist.
57+
"""
58+
if not STATS_FILE.exists():
59+
return {}
60+
try:
61+
with open(STATS_FILE, "r") as f:
62+
return json.load(f)
63+
except (json.JSONDecodeError, FileNotFoundError):
64+
return {}
65+
66+
def show_stats():
67+
"""
68+
Loads and prints a formatted dashboard of the validation statistics.
69+
This function handles all presentation logic.
70+
"""
71+
stats = get_stats() # This is the main change: call the new data function
72+
if not stats:
73+
print("No validation statistics recorded yet.")
74+
return
75+
76+
# Prepare data for the table
77+
table_data = []
78+
for country, counts in stats.items():
79+
valid = counts.get("valid", 0)
80+
invalid = counts.get("invalid", 0)
81+
total = valid + invalid
82+
table_data.append([country, valid, invalid, total])
83+
84+
# Sort by total validations
85+
table_data.sort(key=lambda row: row[3], reverse=True)
86+
87+
# --- Print Table ---
88+
print("\nPostal Code Validation Stats (Local Project)")
89+
header = ["Country", "Valid", "Invalid", "Total"]
90+
col_widths = [len(h) for h in header]
91+
for row in table_data:
92+
for i, cell in enumerate(row):
93+
col_widths[i] = max(col_widths[i], len(str(cell)))
94+
95+
header_line = " | ".join(h.ljust(w) for h, w in zip(header, col_widths))
96+
separator = "-+-".join("-" * w for w in col_widths)
97+
print(header_line)
98+
print(separator)
99+
100+
for row in table_data:
101+
row_line = " | ".join(str(cell).ljust(w) for cell, w in zip(row, col_widths))
102+
print(row_line)
103+
104+
# --- Print Bar Chart ---
105+
print("\nValidation Success Rate")
106+
for country, valid, invalid, total in table_data:
107+
if total == 0:
108+
success_rate = 0
109+
else:
110+
success_rate = (valid / total) * 100
111+
bar_length = 40
112+
filled_length = int(bar_length * success_rate / 100)
113+
bar = "█" * filled_length + " " * (bar_length - filled_length)
114+
print(f"{country.ljust(5)} |{bar}| {success_rate:.0f}% valid")

src/postal_regex/cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import argparse
2+
from . import analytics
3+
4+
def main():
5+
"""Main function for the command-line interface."""
6+
parser = argparse.ArgumentParser(
7+
description="Postal Regex command-line tools."
8+
)
9+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
10+
11+
# The 'stats' command
12+
stats_parser = subparsers.add_parser(
13+
"stats", help="Display local validation statistics."
14+
)
15+
stats_parser.add_argument(
16+
"--reset", action="store_true", help="Reset all recorded statistics."
17+
)
18+
19+
args = parser.parse_args()
20+
21+
if args.command == "stats":
22+
if args.reset:
23+
analytics.reset_stats()
24+
else:
25+
analytics.show_stats()
26+
else:
27+
parser.print_help()
28+
29+
if __name__ == "__main__":
30+
main()

src/postal_regex/core.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ def validate(country_identifier: str, postal_code: str, timeout: float = 0.1) ->
7171
try:
7272
return bool(entry.regex.fullmatch(postal_code, timeout=timeout))
7373
except regex.TimeoutError:
74-
return False # treat timeout as invalid
75-
74+
return False
7675

7776
def get_supported_countries():
7877
"""

tests/test_analytics.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import json
2+
from pathlib import Path
3+
import sys
4+
5+
# Add src folder to sys.path
6+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
7+
SRC_DIR = PROJECT_ROOT / "src"
8+
sys.path.insert(0, str(SRC_DIR))
9+
10+
from postal_regex import analytics
11+
12+
def test_record_validation_and_load_stats(monkeypatch):
13+
"""
14+
Test that recording validations correctly creates and updates the stats file.
15+
"""
16+
temp_stats_file = Path.home() / ".postalregex_stats_temp.json"
17+
monkeypatch.setattr(analytics, 'STATS_FILE', temp_stats_file)
18+
19+
# Ensure the file doesn't exist initially
20+
if temp_stats_file.exists():
21+
temp_stats_file.unlink()
22+
23+
analytics.record_validation("US", is_valid=True)
24+
25+
with open(temp_stats_file, "r") as f:
26+
stats = json.load(f)
27+
28+
assert stats["US"]["valid"] == 1
29+
assert stats["US"]["invalid"] == 0
30+
31+
analytics.record_validation("US", is_valid=False)
32+
33+
with open(temp_stats_file, "r") as f:
34+
stats = json.load(f)
35+
36+
assert stats["US"]["valid"] == 1
37+
assert stats["US"]["invalid"] == 1
38+
39+
analytics.record_validation("CA", is_valid=True)
40+
41+
with open(temp_stats_file, "r") as f:
42+
stats = json.load(f)
43+
44+
assert stats["CA"]["valid"] == 1
45+
assert "invalid" in stats["CA"] # an 'invalid' key should be created
46+
47+
temp_stats_file.unlink()
48+
49+
def test_get_stats(monkeypatch):
50+
"""
51+
Test that get_stats correctly reads and returns data.
52+
"""
53+
temp_stats_file = Path.home() / ".postalregex_stats_temp.json"
54+
monkeypatch.setattr(analytics, 'STATS_FILE', temp_stats_file)
55+
56+
# 1. Test when file doesn't exist
57+
if temp_stats_file.exists():
58+
temp_stats_file.unlink()
59+
assert analytics.get_stats() == {}
60+
61+
dummy_data = {"US": {"valid": 5, "invalid": 1}}
62+
with open(temp_stats_file, "w") as f:
63+
json.dump(dummy_data, f)
64+
65+
assert analytics.get_stats() == dummy_data
66+
67+
temp_stats_file.unlink()
68+
69+
def test_reset_stats(monkeypatch):
70+
"""
71+
Test that reset_stats correctly deletes the statistics file.
72+
"""
73+
temp_stats_file = Path.home() / ".postalregex_stats_temp.json"
74+
monkeypatch.setattr(analytics, 'STATS_FILE', temp_stats_file)
75+
76+
with open(temp_stats_file, "w") as f:
77+
json.dump({"US": {"valid": 1, "invalid": 0}}, f)
78+
79+
assert temp_stats_file.exists()
80+
81+
analytics.reset_stats()
82+
83+
assert not temp_stats_file.exists()

0 commit comments

Comments
 (0)