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 .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "tracking_number_data"]
path = tracking_number_data
url = [email protected]:jcomo/tracking_number_data
url = [email protected]:jkeen/tracking_number_data
22 changes: 9 additions & 13 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.1.0
rev: v6.0.0
hooks:
- id: check-docstring-first
- id: check-executables-have-shebangs
Expand All @@ -13,37 +13,33 @@ repos:
- id: trailing-whitespace
exclude: '^tests/fixtures/.*'
- repo: https://github.com/pycqa/flake8
rev: '4.0.1'
rev: '7.3.0'
hooks:
- id: flake8
additional_dependencies: [
flake8-tidy-imports==4.5.0,
flake8-logging-format==0.6.0,
flake8-tidy-imports==4.11.0,
]
- repo: https://github.com/asottile/pyupgrade
rev: v1.14.0
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py36-plus]
- repo: https://github.com/psf/black
rev: 22.3.0
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/asottile/add-trailing-comma
rev: v1.0.0
rev: v3.2.0
hooks:
- id: add-trailing-comma
args: [--py36-plus]
- repo: https://github.com/asottile/reorder_python_imports
rev: v1.4.0
rev: v3.15.0
hooks:
- id: reorder-python-imports
args: [--py3-plus]
- repo: https://github.com/thlorenz/doctoc
rev: v1.4.0
rev: v2.2.0
hooks:
- id: doctoc
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.782
rev: v1.17.1
hooks:
- id: mypy
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Usage](#usage)
- [`get_tracking_number(number)`](#get_tracking_numbernumber)
- [`get_definition(product_name)`](#get_definitionproduct_name)
- [Updating the definitions](#updating-the-definitions)
- [Testing](#testing)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -84,6 +85,26 @@ tracking_number = ups_definition.test('some_valid_fedex_number')
# => None
```

## Updating the definitions

The source [`tracking_number_data`](https://github.com/jkeen/tracking_number_data/) is
occasionally updated. To re-generate this library please run the following:

```sh
# Fetch the latest tracking_number_data
git submodule init
git submodule update --remote --merge

# Regenerate the tracking number definitions
python codegen.py

# Format and Lint the code
pre-commit run --all-files

# Finally test
pytest
```

## Testing

We use the test cases defined in the courier data to generate pytest test cases.
Expand Down
5 changes: 4 additions & 1 deletion codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
import_statements = [
"import re",
"",
"from tracking_numbers.checksum_validator import Luhn",
"from tracking_numbers.checksum_validator import Mod10",
"from tracking_numbers.checksum_validator import Mod7",
"from tracking_numbers.checksum_validator import Mod_37_36",
"from tracking_numbers.checksum_validator import S10",
"from tracking_numbers.checksum_validator import SumProductWithWeightsAndModulo",
"from tracking_numbers.definition import AdditionalValidation",
"from tracking_numbers.definition import Additional",
"from tracking_numbers.definition import AdditionalValidator",
"from tracking_numbers.definition import TrackingNumberDefinition",
"from tracking_numbers.serial_number import DefaultSerialNumberParser",
"from tracking_numbers.serial_number import PrependIf",
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ max-line-length = 120
per-file-ignores =
tracking_numbers/__init__.py:F401
tracking_numbers/_generated.py:E501

exclude =
tracking_number_data
39 changes: 39 additions & 0 deletions tests/fixtures/example_s10.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"id": "s10",
"name": "S10",
"validation": {
"checksum": {
"name": "s10"
},
"additional": {
"exists": ["Courier"]
}
},
"regex": "\\s*(?<ServiceType>([A-Z]\\s*){2})(?<SerialNumber>([0-9]\\s*){8})(?<CheckDigit>([0-9]\\s*))(?<CountryCode>([A-Z]\\s*){2})",
"additional": [
{
"name": "Service Type",
"regex_group_name": "ServiceType",
"lookup": [
{
"name": "Letter Post Registered",
"matches_regex": "R[A-Z]",
"description": "Prepaid first-class mail."
}
]
},
{
"name": "Courier",
"regex_group_name": "CountryCode",
"lookup": [
{
"country": "Great Britain",
"matches": "GB",
"courier": "Royal Mail Group plc",
"courier_url": "http://www.royalmail.com/postcode-finder?gear=postcode&campaignid=postcodefinder_redirect",
"upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/great-britain.html"
}
]
}
]
}
64 changes: 64 additions & 0 deletions tests/test_additional_information.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import json
import os

from tracking_numbers.definition import TrackingNumberDefinition
from tracking_numbers.types import Courier

# Load the S10 tracking number spec from the JSON file
spec_path = os.path.join(os.path.dirname(__file__), "fixtures/example_s10.json")
with open(spec_path) as f:
tn_spec = json.load(f)

courier = Courier(
name="S10 International Standard",
code="s10",
)

definition = TrackingNumberDefinition.from_spec(courier, tn_spec)


def test_s10_additional_info():
# Test case for extracting additional information from the tracking number
tracking = definition.test("RB123456785GB")

assert tracking is not None
assert tracking.courier_info == {
"code": "s10",
"name": "Royal Mail Group plc",
"url": "http://www.royalmail.com/postcode-finder?gear=postcode&campaignid=postcodefinder_redirect",
"upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/great-britain.html",
"country": "Great Britain",
}

assert tracking.service_type == {
"code": "RB",
"name": "Letter Post Registered",
"description": "Prepaid first-class mail.",
}

assert tracking.valid


def test_s10_missing_additional_info():
print(definition)

tracking = definition.test("AB123456785NP")
assert tracking is not None

# AB is a unknown courier code, so we default back to s10
assert tracking.courier_info == {
"code": "s10",
"name": "S10 International Standard",
}

assert tracking.service_type == {
"code": "AB",
}

assert not tracking.valid
assert tracking.validation_errors == [
(
"Courier",
"Courier not found in additional information",
),
]
133 changes: 133 additions & 0 deletions tests/test_checksum_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from typing import Dict

import pytest

from tracking_numbers.checksum_validator import Luhn
from tracking_numbers.checksum_validator import Mod10
from tracking_numbers.checksum_validator import Mod7
from tracking_numbers.checksum_validator import Mod_37_36
from tracking_numbers.checksum_validator import S10
from tracking_numbers.checksum_validator import SumProductWithWeightsAndModulo
from tracking_numbers.serial_number import DefaultSerialNumberParser


serials_and_checks = [
(
"12345678",
{
"S10": "5",
"Mod10": "4",
"Mod7": "2",
"Mod_37_36": "W",
"Luhn": "2",
"SumProduct": "4",
},
),
(
"45678",
{
"S10": 8,
"Mod10": "0",
"Mod7": "3",
"Mod_37_36": "V",
"Luhn": "0",
"SumProduct": "2",
},
),
(
"00007",
{
"S10": 1,
"Mod10": "3",
"Mod7": "0",
"Mod_37_36": "I",
"Luhn": "5",
"SumProduct": "0",
},
),
(
"A12345",
{
"Mod_37_36": "J",
},
),
]

algorithms = {
"S10": S10(),
"Mod10": Mod10(),
"Mod7": Mod7(),
"Mod_37_36": Mod_37_36(),
"Luhn": Luhn(),
"SumProduct": SumProductWithWeightsAndModulo([1, 2, 3], 10, 5),
}

parser = DefaultSerialNumberParser()


@pytest.mark.parametrize("serial, checks", serials_and_checks)
@pytest.mark.parametrize("algo_name", algorithms.keys())
def test_valid_checksum(algo_name, serial: str, checks: Dict[str, str]):
validator = algorithms[algo_name]
parsed_serial = parser.parse(serial)

if algo_name not in checks:
try:
validator._check_digit(parsed_serial)

assert False, (
f"Expected {algo_name} to fail for {serial} "
f"instead it provided check_digit '{validator._check_digit(parsed_serial)}'"
)

except ValueError:
pass

else:
check_digit = checks[algo_name]
assert validator.passes(
parsed_serial,
check_digit,
), (
f"Expected {algo_name} to pass for {serial} with check_digit {check_digit} "
f"instead it provided check_digit '{validator._check_digit(parsed_serial)}'"
)


def test_valid_Mod_37_36_checksum():
# A few extra test cases for Mod_37_36 from
# https://esolutions.dpd.com/dokumente/DPD_Parcel_Label_Specification_2.4.1_EN.pdf
test_cases = [
("123AB", "X"),
("ABC987", "E"),
]

validator = algorithms["Mod_37_36"]
for serial, expected in test_cases:
parsed_serial = parser.parse(serial)
assert validator.passes(
parsed_serial,
expected,
), (
f"Expected Mod_37_36 to pass for {serial} with check_digit {expected} "
f"instead it provided check_digit '{validator._check_digit(parsed_serial)}'"
)


def test_valid_Luhn_checksum():
# A few extra test cases for Luhn from
# https://en.wikipedia.org/wiki/Luhn_algorithm
test_cases = [
("1789372997", "4"),
]

validator = algorithms["Luhn"]
for serial, expected in test_cases:
parsed_serial = parser.parse(serial)
assert validator.passes(
parsed_serial,
expected,
), (
f"Expected Luhn to pass for {serial} with check_digit {expected} "
f"instead it provided check_digit '{validator._check_digit(parsed_serial)}'"
)
12 changes: 10 additions & 2 deletions tests/test_tracking_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ def pytest_generate_tests(metafunc):
)


def test_tracking_numbers(definition, number, expected_valid):
def test_tracking_numbers(
definition: TrackingNumberDefinition,
number: str,
expected_valid: bool,
):
tracking_number = definition.test(number)
if not tracking_number:
assert not expected_valid, "Expected valid tracking number, but wasn't detected"
elif not tracking_number.valid:
assert not expected_valid, "Expected valid tracking number, but was invalid"
assert (
not expected_valid
), "Expected valid tracking number, but was invalid. Reasons: {}".format(
tracking_number.validation_errors,
)
elif tracking_number.valid:
assert expected_valid, "Expected invalid tracking number, but was valid"
Loading