Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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-01-01",
"max": 606,
"min": 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where'd you find this data

@Luis-manzur Luis-manzur Jan 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked gemini about volume 606, and told me that Part 1 started around January 25

}
}
},
"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