Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- Add Wash App 2d
- Add Arizona Digest
- Add variations

- Add volume range metadata for reporters #245

## Current Version
- 3.2.62 (2025-11-21)
Expand Down
36 changes: 36 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ A few specialized reporter-related variables are:

- ``NAMES_TO_EDITIONS`` — A simple dict to map the name of a reporter back to its canonilcal abbreviations. For example, ``Atlantic Reporter`` maps to ``['A.', 'A.2d']``.

Volume Range Functions
----------------------

The following utility functions are available for validating citation volumes:

- ``get_volume_ranges()`` — Returns a dict mapping reporter abbreviations to ``(min, max)`` tuples for all reporters with volume range data.

- ``get_volume_range(reporter)`` — Returns a ``(min, max)`` tuple for a specific reporter, or ``None`` if not found.

- ``is_volume_valid(reporter, volume, tolerance_multiplier=1.5)`` — Returns a tuple of ``(is_valid, reason)`` indicating whether a volume number is valid for a reporter.

- ``uses_year_as_volume(reporter)`` — Returns ``True`` if the reporter uses publication year as volume number (common for neutral citations).

Example usage::

from reporters_db import get_volume_range, is_volume_valid

# Get range for a reporter
min_vol, max_vol = get_volume_range("U.S.")
print(f"U.S. Reports: volumes {min_vol} to {max_vol}")

# Validate a volume
is_valid, reason = is_volume_valid("U.S.", 500)
if not is_valid:
print(f"Invalid: {reason}")

Volume Range Fields
~~~~~~~~~~~~~~~~~~~

The ``volume_range`` object in edition data contains:

- ``min``: Minimum known volume number
- ``max``: Maximum known volume number (as of last update)
- ``uses_year``: True if this reporter uses publication year as volume (optional)
- ``last_updated``: Date when the max was last verified (optional)

CSV
===

Expand Down
12 changes: 12 additions & 0 deletions reporters_db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
import json
import os

from .utils import (
get_volume_range as get_volume_range,
)
from .utils import (
get_volume_ranges as get_volume_ranges,
)
from .utils import (
is_volume_valid as is_volume_valid,
)
from .utils import (
names_to_abbreviations,
process_variables,
suck_out_editions,
suck_out_formats,
suck_out_variations_only,
)
from .utils import (
uses_year_as_volume as uses_year_as_volume,
)


def datetime_parser(dct):
Expand Down
7 changes: 6 additions & 1 deletion reporters_db/data/reporters.json
Original file line number Diff line number Diff line change
Expand Up @@ -25987,7 +25987,12 @@
"$full_cite",
"$volume $reporter\\s*\\((?:(?P<volume_nominative>\\d{1,2}) )?(?P<reporter_nominative>Black|Cranch|Pet.|Wall.|How.|Wheat.|Dall.)\\) $page"
],
"start": "1875-01-01T00:00:00"
"start": "1875-01-01T00:00:00",
"volume_range": {
"last_updated": "2025-10-14",
"max": 607,
"min": 1
}
}
},
"examples": [
Expand Down
128 changes: 128 additions & 0 deletions reporters_db/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import json
import re
from collections import OrderedDict
from pathlib import Path
from string import Template
from typing import Optional


def suck_out_variations_only(reporters):
Expand Down Expand Up @@ -180,3 +183,128 @@ def substitute_editions(regex, edition_name, variations):
k for k, v in variations.items() if v == edition_name
]
return [substitute_edition(regex, e) for e in edition_strings]


def load_reporters() -> dict:
"""Load the reporters.json data."""
data_path = Path(__file__).parent / "data" / "reporters.json"
with open(data_path, encoding="utf-8") as f:
return json.load(f)


def get_volume_ranges() -> dict[str, tuple[int, int]]:
"""Get volume ranges for all reporters with range data.

Returns:
Dictionary mapping reporter abbreviation to (min, max) tuple.

Example:
>>> ranges = get_volume_ranges()
>>> ranges["U.S."]
(1, 606)
"""
reporters = load_reporters()
ranges = {}

for _reporter_key, reporter_list in reporters.items():
for reporter in reporter_list:
for edition_key, edition_data in reporter.get(
"editions", {}
).items():
if volume_range := edition_data.get("volume_range"):
ranges[edition_key] = (
volume_range["min"],
volume_range["max"],
)

return ranges


def get_volume_range(reporter: str) -> Optional[tuple[int, int]]:
"""Get the volume range for a specific reporter.

Args:
reporter: Reporter abbreviation (e.g., "U.S.", "F.2d")

Returns:
Tuple of (min_volume, max_volume) or None if not found.

Example:
>>> get_volume_range("U.S.")
(1, 606)
>>> get_volume_range("Unknown Reporter")
None
"""
ranges = get_volume_ranges()
return ranges.get(reporter)


def is_volume_valid(
reporter: str,
volume: int,
tolerance_multiplier: float = 1.5,
) -> tuple[bool, str]:
"""Check if a volume number is valid for a reporter.

Args:
reporter: Reporter abbreviation
volume: Volume number to validate
tolerance_multiplier: Multiplier for max volume to allow new volumes

Returns:
Tuple of (is_valid, reason). If valid, reason is empty string.

Example:
>>> is_volume_valid("U.S.", 500)
(True, "")
>>> is_volume_valid("U.S.", 5000)
(False, "Volume 5000 exceeds maximum 909 for U.S.")
"""
volume_range = get_volume_range(reporter)

if volume_range is None:
# No data for this reporter, assume valid
return True, ""

min_vol, max_vol = volume_range
max_with_tolerance = int(max_vol * tolerance_multiplier)

if volume < min_vol:
return (
False,
f"Volume {volume} is below minimum {min_vol} for {reporter}",
)

if volume > max_with_tolerance:
return (
False,
f"Volume {volume} exceeds maximum {max_with_tolerance} for {reporter}",
)

return True, ""


def uses_year_as_volume(reporter: str) -> bool:
"""Check if a reporter uses publication year as volume number.

Some reporters (like neutral citations) use the year as volume.
These need different validation logic.

Args:
reporter: Reporter abbreviation

Returns:
True if this reporter uses year as volume, False otherwise.
"""
reporters = load_reporters()

for _reporter_key, reporter_list in reporters.items():
for reporter_data in reporter_list:
for edition_key, edition_data in reporter_data.get(
"editions", {}
).items():
if edition_key == reporter:
volume_range = edition_data.get("volume_range", {})
return volume_range.get("uses_year", False)

return False
26 changes: 26 additions & 0 deletions schemas/reporters.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@
"string",
"null"
]
},
"volume_range": {
"type": "object",
"description": "Known volume range for this edition",
"properties": {
"min": {
"type": "integer",
"description": "Minimum known volume number",
"minimum": 0
},
"max": {
"type": "integer",
"description": "Maximum known volume number as of last update"
},
"uses_year": {
"type": "boolean",
"default": false,
"description": "True if this reporter uses publication year as volume"
},
"last_updated": {
"type": "string",
"format": "date",
"description": "Date when max was last verified"
}
},
"required": ["min", "max"]
}
},
"required": [
Expand Down
101 changes: 101 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
REGEX_VARIABLES,
REPORTERS,
VARIATIONS_ONLY,
get_volume_range,
get_volume_ranges,
is_volume_valid,
uses_year_as_volume,
)
from reporters_db.utils import recursive_substitute

Expand Down Expand Up @@ -399,6 +403,103 @@ def test_fields_tidy(self):
self.check_whitespace(JOURNALS)


class VolumeRangeTests(TestCase):
"""Tests for volume range functionality."""

def test_get_volume_ranges_returns_dict(self):
"""get_volume_ranges should return a dictionary."""
ranges = get_volume_ranges()
self.assertIsInstance(ranges, dict)

def test_get_volume_range_known_reporter(self):
"""get_volume_range should return tuple for known reporters."""
# This test assumes U.S. has volume_range data
result = get_volume_range("U.S.")
if result is not None:
self.assertIsInstance(result, tuple)
self.assertEqual(len(result), 2)
min_vol, max_vol = result
self.assertGreaterEqual(min_vol, 1)
self.assertGreater(max_vol, min_vol)

def test_get_volume_range_unknown_reporter(self):
"""get_volume_range should return None for unknown reporters."""
result = get_volume_range("Definitely Not A Real Reporter")
self.assertIsNone(result)

def test_is_volume_valid_within_range(self):
"""Volumes within range should be valid."""
# Assuming U.S. has range data with max around 600
is_valid, reason = is_volume_valid("U.S.", 500)
# If no range data, it defaults to valid
self.assertTrue(is_valid)

def test_is_volume_valid_unknown_reporter(self):
"""Unknown reporters should default to valid."""
is_valid, reason = is_volume_valid("Unknown Reporter", 99999)
self.assertTrue(is_valid)
self.assertEqual(reason, "")

def test_is_volume_valid_below_minimum(self):
"""Volumes below minimum should be invalid."""
# Assuming U.S. has range data with min = 1
result = get_volume_range("U.S.")
if result is not None:
is_valid, reason = is_volume_valid("U.S.", 0)
self.assertFalse(is_valid)
self.assertIn("below minimum", reason)

def test_is_volume_valid_above_maximum(self):
"""Volumes way above maximum should be invalid."""
# Assuming U.S. has range data
result = get_volume_range("U.S.")
if result is not None:
is_valid, reason = is_volume_valid("U.S.", 50000)
self.assertFalse(is_valid)
self.assertIn("exceeds maximum", reason)

def test_uses_year_as_volume_false_for_regular_reporter(self):
"""Regular reporters should not use year as volume."""
result = uses_year_as_volume("U.S.")
self.assertFalse(result)

def test_uses_year_as_volume_unknown_reporter(self):
"""Unknown reporters should return False."""
result = uses_year_as_volume("Unknown Reporter")
self.assertFalse(result)

def test_volume_range_schema_valid(self):
"""All volume_range objects should have required fields."""
ranges_data = get_volume_ranges()
for reporter, (min_vol, max_vol) in ranges_data.items():
self.assertIsInstance(min_vol, int, f"{reporter} min is not int")
self.assertIsInstance(max_vol, int, f"{reporter} max is not int")
self.assertGreaterEqual(min_vol, 0, f"{reporter} min is negative")
self.assertGreater(max_vol, 0, f"{reporter} max is not positive")
self.assertLessEqual(min_vol, max_vol, f"{reporter} min > max")


class VolumeRangeIntegrationTests(TestCase):
"""Integration tests for volume ranges in reporters.json."""

def test_all_editions_volume_range_valid(self):
"""If volume_range exists, it should be properly formatted."""
for _reporter_key, reporter_list in REPORTERS.items():
for reporter in reporter_list:
for edition_key, edition_data in reporter.get(
"editions", {}
).items():
if "volume_range" in edition_data:
vr = edition_data["volume_range"]
self.assertIn("min", vr, f"{edition_key} missing min")
self.assertIn("max", vr, f"{edition_key} missing max")
self.assertGreaterEqual(
vr["max"],
vr["min"],
f"{edition_key} max < min",
)


# avoid running test methods in BaseTestCase itself
del BaseTestCase

Expand Down
Loading