rigour 2.2.0__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {rigour-2.2.0 → rigour-2.2.2}/PKG-INFO +2 -1
  2. {rigour-2.2.0 → rigour-2.2.2}/pyproject.toml +2 -1
  3. rigour-2.2.2/rigour/dates.py +203 -0
  4. rigour-2.2.2/rigour/ids/imo.py +125 -0
  5. {rigour-2.2.0 → rigour-2.2.2}/rust/Cargo.lock +1 -1
  6. {rigour-2.2.0 → rigour-2.2.2}/rust/Cargo.toml +1 -1
  7. rigour-2.2.0/rigour/ids/imo.py +0 -60
  8. {rigour-2.2.0 → rigour-2.2.2}/LICENSE +0 -0
  9. {rigour-2.2.0 → rigour-2.2.2}/README.md +0 -0
  10. {rigour-2.2.0 → rigour-2.2.2}/rigour/__init__.py +0 -0
  11. {rigour-2.2.0 → rigour-2.2.2}/rigour/_core.pyi +0 -0
  12. {rigour-2.2.0 → rigour-2.2.2}/rigour/addresses/__init__.py +0 -0
  13. {rigour-2.2.0 → rigour-2.2.2}/rigour/addresses/cleaning.py +0 -0
  14. {rigour-2.2.0 → rigour-2.2.2}/rigour/addresses/format.py +0 -0
  15. {rigour-2.2.0 → rigour-2.2.2}/rigour/addresses/normalize.py +0 -0
  16. {rigour-2.2.0 → rigour-2.2.2}/rigour/boolean.py +0 -0
  17. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/__init__.py +0 -0
  18. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/addresses/__init__.py +0 -0
  19. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/addresses/data.py +0 -0
  20. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/addresses/formats.yml +0 -0
  21. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/langs/__init__.py +0 -0
  22. {rigour-2.2.0 → rigour-2.2.2}/rigour/data/langs/iso639.py +0 -0
  23. {rigour-2.2.0 → rigour-2.2.2}/rigour/env.py +0 -0
  24. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/__init__.py +0 -0
  25. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/common.py +0 -0
  26. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/npi.py +0 -0
  27. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/ogrn.py +0 -0
  28. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/stdnum_.py +0 -0
  29. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/strict.py +0 -0
  30. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/uei.py +0 -0
  31. {rigour-2.2.0 → rigour-2.2.2}/rigour/ids/wikidata.py +0 -0
  32. {rigour-2.2.0 → rigour-2.2.2}/rigour/langs/__init__.py +0 -0
  33. {rigour-2.2.0 → rigour-2.2.2}/rigour/langs/synonyms.py +0 -0
  34. {rigour-2.2.0 → rigour-2.2.2}/rigour/langs/text.py +0 -0
  35. {rigour-2.2.0 → rigour-2.2.2}/rigour/langs/util.py +0 -0
  36. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/__init__.py +0 -0
  37. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/filename.py +0 -0
  38. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/mappings.py +0 -0
  39. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/mime.py +0 -0
  40. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/parse.py +0 -0
  41. {rigour-2.2.0 → rigour-2.2.2}/rigour/mime/types.py +0 -0
  42. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/__init__.py +0 -0
  43. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/analyze.py +0 -0
  44. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/check.py +0 -0
  45. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/compare.py +0 -0
  46. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/name.py +0 -0
  47. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/ordering.py +0 -0
  48. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/org_types.py +0 -0
  49. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/part.py +0 -0
  50. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/pick.py +0 -0
  51. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/prefix.py +0 -0
  52. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/split_phrases.py +0 -0
  53. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/symbol.py +0 -0
  54. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/tag.py +0 -0
  55. {rigour-2.2.0 → rigour-2.2.2}/rigour/names/tokenize.py +0 -0
  56. {rigour-2.2.0 → rigour-2.2.2}/rigour/py.typed +0 -0
  57. {rigour-2.2.0 → rigour-2.2.2}/rigour/reset.py +0 -0
  58. {rigour-2.2.0 → rigour-2.2.2}/rigour/territories/__init__.py +0 -0
  59. {rigour-2.2.0 → rigour-2.2.2}/rigour/territories/lookup.py +0 -0
  60. {rigour-2.2.0 → rigour-2.2.2}/rigour/territories/match.py +0 -0
  61. {rigour-2.2.0 → rigour-2.2.2}/rigour/territories/territory.py +0 -0
  62. {rigour-2.2.0 → rigour-2.2.2}/rigour/territories/util.py +0 -0
  63. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/__init__.py +0 -0
  64. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/checksum.py +0 -0
  65. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/cleaning.py +0 -0
  66. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/distance.py +0 -0
  67. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/normalize.py +0 -0
  68. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/phonetics.py +0 -0
  69. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/scripts.py +0 -0
  70. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/stopwords.py +0 -0
  71. {rigour-2.2.0 → rigour-2.2.2}/rigour/text/translit.py +0 -0
  72. {rigour-2.2.0 → rigour-2.2.2}/rigour/time.py +0 -0
  73. {rigour-2.2.0 → rigour-2.2.2}/rigour/units.py +0 -0
  74. {rigour-2.2.0 → rigour-2.2.2}/rigour/urls/__init__.py +0 -0
  75. {rigour-2.2.0 → rigour-2.2.2}/rigour/urls/cleaning.py +0 -0
  76. {rigour-2.2.0 → rigour-2.2.2}/rigour/urls/compare.py +0 -0
  77. {rigour-2.2.0 → rigour-2.2.2}/rigour/urls/util.py +0 -0
  78. {rigour-2.2.0 → rigour-2.2.2}/rigour/util.py +0 -0
  79. {rigour-2.2.0 → rigour-2.2.2}/rust/build.rs +0 -0
  80. {rigour-2.2.0 → rigour-2.2.2}/rust/data/names/compare.json +0 -0
  81. {rigour-2.2.0 → rigour-2.2.2}/rust/data/names/org_types.json +0 -0
  82. {rigour-2.2.0 → rigour-2.2.2}/rust/data/names/person_names.txt +0 -0
  83. {rigour-2.2.0 → rigour-2.2.2}/rust/data/names/stopwords.json +0 -0
  84. {rigour-2.2.0 → rigour-2.2.2}/rust/data/names/symbols.json +0 -0
  85. {rigour-2.2.0 → rigour-2.2.2}/rust/data/territories/data.jsonl +0 -0
  86. {rigour-2.2.0 → rigour-2.2.2}/rust/data/text/ordinals.json +0 -0
  87. {rigour-2.2.0 → rigour-2.2.2}/rust/data/text/stopwords.json +0 -0
  88. {rigour-2.2.0 → rigour-2.2.2}/rust/src/constants.rs +0 -0
  89. {rigour-2.2.0 → rigour-2.2.2}/rust/src/generated/.gitkeep +0 -0
  90. {rigour-2.2.0 → rigour-2.2.2}/rust/src/lib.rs +0 -0
  91. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/alignment.rs +0 -0
  92. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/analyze.rs +0 -0
  93. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/compare.rs +0 -0
  94. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/matcher.rs +0 -0
  95. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/mod.rs +0 -0
  96. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/name.rs +0 -0
  97. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/ordering.rs +0 -0
  98. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/org_types.rs +0 -0
  99. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/pairing.rs +0 -0
  100. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/part.rs +0 -0
  101. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/person_names.rs +0 -0
  102. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/pick.rs +0 -0
  103. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/prefix.rs +0 -0
  104. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/stopwords.rs +0 -0
  105. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/symbol.rs +0 -0
  106. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/symbols.rs +0 -0
  107. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/tag.rs +0 -0
  108. {rigour-2.2.0 → rigour-2.2.2}/rust/src/names/tagger.rs +0 -0
  109. {rigour-2.2.0 → rigour-2.2.2}/rust/src/territories.rs +0 -0
  110. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/distance.rs +0 -0
  111. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/mod.rs +0 -0
  112. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/normalize.rs +0 -0
  113. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/numbers.rs +0 -0
  114. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/ordinals.rs +0 -0
  115. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/phonetics.rs +0 -0
  116. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/scripts.rs +0 -0
  117. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/stopwords.rs +0 -0
  118. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/tokenize.rs +0 -0
  119. {rigour-2.2.0 → rigour-2.2.2}/rust/src/text/translit.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rigour
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Classifier: Intended Audience :: Developers
5
5
  Classifier: Operating System :: OS Independent
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.14
12
12
  Classifier: Programming Language :: Rust
13
13
  Requires-Dist: pyyaml>=5.0.0,<7.0.0
14
14
  Requires-Dist: normality>=3.0.1,<4.0.0
15
+ Requires-Dist: prefixdate>=0.5.0,<1.0.0
15
16
  Requires-Dist: orjson>=3.0.0,<4.0.0
16
17
  Requires-Dist: python-stdnum>=2.0,<3.0.0
17
18
  Requires-Dist: jinja2>=3.1.0,<4.0.0
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rigour"
7
- version = "2.2.0"
7
+ version = "2.2.2"
8
8
  description = "Financial crime domain data validation and normalization library."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -24,6 +24,7 @@ requires-python = ">= 3.10"
24
24
  dependencies = [
25
25
  "pyyaml >= 5.0.0, < 7.0.0",
26
26
  "normality >= 3.0.1, < 4.0.0",
27
+ "prefixdate >= 0.5.0, < 1.0.0",
27
28
  "orjson >= 3.0.0, < 4.0.0",
28
29
  "python-stdnum >= 2.0, < 3.0.0",
29
30
  "jinja2 >= 3.1.0, < 4.0.0",
@@ -0,0 +1,203 @@
1
+ """Compare prefix dates without pretending they are exact instants.
2
+
3
+ A prefix date such as ``2026`` or ``2026-06`` represents an interval, rather
4
+ than the first instant of that year or month. Use the string wrappers for
5
+ one-off comparisons, or parse a [DateInterval][rigour.dates.DateInterval] once
6
+ when comparing the same value repeatedly.
7
+
8
+ Age and recency checks use the same comparisons with a shifted reference:
9
+
10
+ ```python
11
+ cutoff = now - max_age
12
+ is_recent = not ended_before(value, cutoff)
13
+ ```
14
+ """
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timedelta, timezone
19
+ from functools import lru_cache
20
+
21
+ from prefixdate import Precision, parse
22
+
23
+ from rigour.util import MEMO_SMALL
24
+
25
+ _TIMEZONE_SUFFIX_RE = re.compile(r"(?:Z|[+-]\d{2}(?::?\d{2})?)$")
26
+ _SECOND_TIMESTAMP_RE = re.compile(
27
+ r"^[12]\d{3}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
28
+ r"(?:Z|[+-]\d{2}(?::?\d{2})?)?$"
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DateInterval:
34
+ """Represent every possible instant described by an imprecise date.
35
+
36
+ Use this parsed form when applying multiple comparisons to the same prefix
37
+ date. Both bounds are timezone-aware UTC datetimes and ``end`` is exclusive.
38
+
39
+ Attributes:
40
+ start: Earliest instant represented by the date.
41
+ end: First instant after the represented interval.
42
+ """
43
+
44
+ start: datetime
45
+ end: datetime
46
+
47
+
48
+ def _ensure_utc(value: datetime) -> datetime:
49
+ """Adapt legacy naive-UTC values to aware UTC datetimes."""
50
+ if value.tzinfo is None or value.utcoffset() is None:
51
+ return value.replace(tzinfo=timezone.utc)
52
+ return value.astimezone(timezone.utc)
53
+
54
+
55
+ def parse_utc(value: str) -> datetime:
56
+ """Parse an exact timestamp as an aware UTC datetime.
57
+
58
+ Use this at a prefix-date boundary where an exact timestamp may carry an
59
+ offset. Naive timestamps use the ecosystem convention of implicit UTC.
60
+
61
+ Args:
62
+ value: Canonical second-precision timestamp.
63
+
64
+ Returns:
65
+ The represented instant as a timezone-aware UTC datetime.
66
+
67
+ Raises:
68
+ ValueError: The timestamp is invalid or is not precise to the second.
69
+ """
70
+ if _SECOND_TIMESTAMP_RE.fullmatch(value) is None:
71
+ raise ValueError(f"Invalid exact timestamp: {value!r}")
72
+ try:
73
+ timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
74
+ except ValueError as exc:
75
+ raise ValueError(f"Invalid timestamp: {value!r}") from exc
76
+ return _ensure_utc(timestamp)
77
+
78
+
79
+ @lru_cache(maxsize=MEMO_SMALL)
80
+ def prefix_interval(value: str) -> DateInterval:
81
+ """Expand a canonical prefix date into its represented UTC interval.
82
+
83
+ Use this at the boundary between prefix-date strings and interval comparison.
84
+ Feed it normalized property values only, not uncleaned source strings. Exact
85
+ timestamps with offsets are converted to UTC before their one-second interval
86
+ is constructed.
87
+
88
+ Args:
89
+ value: Canonical prefix date, from year through second precision.
90
+
91
+ Returns:
92
+ The timezone-aware UTC, half-open interval represented by the value.
93
+
94
+ Raises:
95
+ ValueError: The value is invalid or non-canonical, or an imprecise value
96
+ carries a timezone.
97
+ """
98
+ if _SECOND_TIMESTAMP_RE.fullmatch(value) is not None:
99
+ start = parse_utc(value)
100
+ return DateInterval(start=start, end=start + timedelta(seconds=1))
101
+ has_timezone = value.endswith("Z") or (
102
+ "T" in value and _TIMEZONE_SUFFIX_RE.search(value) is not None
103
+ )
104
+ if has_timezone:
105
+ raise ValueError("timezone suffixes require second precision")
106
+
107
+ prefix = parse(value)
108
+ if prefix.dt is None or prefix.text is None or prefix.precision == Precision.EMPTY:
109
+ raise ValueError(f"Invalid prefix date: {value!r}")
110
+ if prefix.text != value:
111
+ raise ValueError(f"Prefix date is not canonical: {value!r}")
112
+
113
+ start = prefix.dt.replace(tzinfo=timezone.utc)
114
+ precision = prefix.precision
115
+ if precision == Precision.YEAR:
116
+ end = start.replace(year=start.year + 1)
117
+ elif precision == Precision.MONTH:
118
+ if start.month == 12:
119
+ end = start.replace(year=start.year + 1, month=1)
120
+ else:
121
+ end = start.replace(month=start.month + 1)
122
+ elif precision == Precision.DAY:
123
+ end = start + timedelta(days=1)
124
+ elif precision == Precision.HOUR:
125
+ end = start + timedelta(hours=1)
126
+ elif precision == Precision.MINUTE:
127
+ end = start + timedelta(minutes=1)
128
+ elif precision == Precision.SECOND:
129
+ end = start + timedelta(seconds=1)
130
+ else:
131
+ raise ValueError(f"Unsupported prefix date precision: {value!r}")
132
+ return DateInterval(start=start, end=end)
133
+
134
+
135
+ def interval_ended_before(value: DateInterval, reference: datetime) -> bool:
136
+ """Check whether an interval has completely elapsed before a point in time.
137
+
138
+ Use this for end dates and age cutoffs when the prefix date has already been
139
+ parsed.
140
+
141
+ Args:
142
+ value: Interval to compare.
143
+ reference: Timezone-aware UTC point in time.
144
+
145
+ Returns:
146
+ ``True`` if every instant represented by the interval precedes the
147
+ reference.
148
+ """
149
+ return value.end <= reference
150
+
151
+
152
+ def interval_starts_after(value: DateInterval, reference: datetime) -> bool:
153
+ """Check whether an interval begins strictly after a point in time.
154
+
155
+ Use this for start dates and future-date guardrails when the prefix date has
156
+ already been parsed.
157
+
158
+ Args:
159
+ value: Interval to compare.
160
+ reference: Timezone-aware UTC point in time.
161
+
162
+ Returns:
163
+ ``True`` if the earliest instant in the interval follows the reference.
164
+ """
165
+ return value.start > reference
166
+
167
+
168
+ def ended_before(value: str, reference: datetime) -> bool:
169
+ """Check whether a prefix date has completely elapsed.
170
+
171
+ Use this string wrapper for one-off end-date and age-cutoff checks. Parse
172
+ once with [prefix_interval][rigour.dates.prefix_interval] when making
173
+ several comparisons against the same value.
174
+
175
+ Args:
176
+ value: Canonical prefix date, from year through second precision.
177
+ reference: UTC point in time. Legacy naive values are interpreted as
178
+ UTC; aware values are converted to UTC.
179
+
180
+ Returns:
181
+ ``True`` if every instant represented by the date precedes the
182
+ reference.
183
+ """
184
+ return interval_ended_before(prefix_interval(value), _ensure_utc(reference))
185
+
186
+
187
+ def starts_after(value: str, reference: datetime) -> bool:
188
+ """Check whether a prefix date begins after a point in time.
189
+
190
+ Use this string wrapper for one-off start-date and future-date checks. Parse
191
+ once with [prefix_interval][rigour.dates.prefix_interval] when reusing the
192
+ same value.
193
+
194
+ Args:
195
+ value: Canonical prefix date, from year through second precision.
196
+ reference: UTC point in time. Legacy naive values are interpreted as
197
+ UTC; aware values are converted to UTC.
198
+
199
+ Returns:
200
+ ``True`` if the earliest instant represented by the date follows the
201
+ reference.
202
+ """
203
+ return interval_starts_after(prefix_interval(value), _ensure_utc(reference))
@@ -0,0 +1,125 @@
1
+ import re
2
+ from typing import Callable, List, Optional, Tuple
3
+
4
+ from rigour.ids.common import IdentifierFormat
5
+
6
+ IMO_RE = re.compile(r"(IMO[\s:.#-]*)?(?<!\d)(\d{5,7})(?!\d)")
7
+
8
+ Checksum = Callable[[List[int]], bool]
9
+
10
+
11
+ def _vessel_checksum(digits: List[int]) -> bool:
12
+ return sum(d * (7 - i) for i, d in enumerate(digits[:6])) % 10 == digits[6]
13
+
14
+
15
+ def _company_checksum(digits: List[int]) -> bool:
16
+ weights = (8, 6, 4, 2, 9, 7)
17
+ checksum = sum(d * w for d, w in zip(digits, weights))
18
+ return (11 - checksum % 11) % 10 == digits[6]
19
+
20
+
21
+ class IMO(IdentifierFormat):
22
+ """An IMO number for a ship or a shipping company.
23
+
24
+ Ship and company IMO numbers are both seven digits but use different check
25
+ digit algorithms; this format admits both. Use
26
+ [is_valid_vessel][rigour.ids.imo.IMO.is_valid_vessel] or
27
+ [is_valid_company][rigour.ids.imo.IMO.is_valid_company] when the value
28
+ must belong to one specific scheme, e.g. when minting scheme-specific
29
+ entity IDs.
30
+
31
+ Extraction considers digit runs of five to seven digits, preferring longer
32
+ runs, then runs carrying an `IMO` prefix, then earlier ones; the first
33
+ candidate that passes a checksum wins. Runs shorter than seven digits are
34
+ left-padded with zeros, since data sources commonly strip leading zeros
35
+ from IMO fields. The all-zero value is rejected.
36
+ """
37
+
38
+ NAME = "imo"
39
+ TITLE = "IMO"
40
+ STRONG = True
41
+
42
+ @classmethod
43
+ def is_valid(cls, text: str) -> bool:
44
+ """Determine if the given text contains a valid IMO number.
45
+
46
+ Args:
47
+ text: A string that may contain an IMO number.
48
+
49
+ Returns:
50
+ True if an IMO number valid in either checksum scheme can be
51
+ extracted from the text.
52
+ """
53
+ return cls._extract(text, (_vessel_checksum, _company_checksum)) is not None
54
+
55
+ @classmethod
56
+ def is_valid_vessel(cls, text: str) -> bool:
57
+ """Determine if the given text contains a valid ship IMO number.
58
+
59
+ Args:
60
+ text: A string that may contain an IMO number.
61
+
62
+ Returns:
63
+ True if an IMO number valid in the vessel checksum scheme can be
64
+ extracted from the text.
65
+ """
66
+ return cls._extract(text, (_vessel_checksum,)) is not None
67
+
68
+ @classmethod
69
+ def is_valid_company(cls, text: str) -> bool:
70
+ """Determine if the given text contains a valid company IMO number.
71
+
72
+ Args:
73
+ text: A string that may contain an IMO number.
74
+
75
+ Returns:
76
+ True if an IMO number valid in the company checksum scheme can be
77
+ extracted from the text.
78
+ """
79
+ return cls._extract(text, (_company_checksum,)) is not None
80
+
81
+ @classmethod
82
+ def normalize(cls, text: str) -> Optional[str]:
83
+ """Extract and normalize an IMO number from the given text.
84
+
85
+ Args:
86
+ text: A string that may contain an IMO number.
87
+
88
+ Returns:
89
+ The number in `IMO0000000` form, or None if the text contains no
90
+ checksum-valid IMO number.
91
+ """
92
+ value = cls._extract(text, (_vessel_checksum, _company_checksum))
93
+ if value is None:
94
+ return None
95
+ return f"IMO{value}"
96
+
97
+ @classmethod
98
+ def _extract(cls, text: str, checksums: Tuple[Checksum, ...]) -> Optional[str]:
99
+ matches = sorted(
100
+ IMO_RE.finditer(text),
101
+ key=lambda m: (-len(m.group(2)), m.group(1) is None, m.start()),
102
+ )
103
+ for match in matches:
104
+ value = match.group(2).zfill(7)
105
+ digits = [int(d) for d in value]
106
+ if not any(digits):
107
+ continue
108
+ if any(check(digits) for check in checksums):
109
+ return value
110
+ return None
111
+
112
+ @classmethod
113
+ def format(cls, value: str) -> str:
114
+ """Format the given value as an IMO number.
115
+
116
+ Args:
117
+ value: A pre-validated IMO number.
118
+
119
+ Returns:
120
+ The value with an `IMO` prefix.
121
+ """
122
+ value = value.replace(" ", "")
123
+ if not value.startswith("IMO"):
124
+ value = f"IMO{value}"
125
+ return value
@@ -831,7 +831,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
831
831
 
832
832
  [[package]]
833
833
  name = "rigour-core"
834
- version = "2.2.0"
834
+ version = "2.2.2"
835
835
  dependencies = [
836
836
  "aho-corasick",
837
837
  "bitflags",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rigour-core"
3
- version = "2.2.0"
3
+ version = "2.2.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.86"
6
6
  license = "MIT"
@@ -1,60 +0,0 @@
1
- import re
2
- from typing import Optional
3
-
4
- from rigour.ids.common import IdentifierFormat
5
-
6
- IMO_RE = re.compile(r"\b(IMO)?(\d{1,7})\b")
7
-
8
-
9
- class IMO(IdentifierFormat):
10
- """An IMO number for a ship or shipping company"""
11
-
12
- NAME = "imo"
13
- TITLE = "IMO"
14
- STRONG = True
15
-
16
- @classmethod
17
- def is_valid(cls, text: str) -> bool:
18
- """Determine if the given string is a valid IMO number.
19
-
20
- Shorter numeric strings are left-padded with zeros to seven digits
21
- before the checksum is verified, since data sources commonly strip
22
- leading zeros from IMO fields.
23
- """
24
- match = IMO_RE.search(text)
25
- if match is None:
26
- return False
27
- value = match.group(2).zfill(7)
28
- digits = [int(d) for d in value]
29
-
30
- # Check if it's a vessel IMO number:
31
- checksum = sum(d * (7 - i) for i, d in enumerate(digits[:-1])) % 10
32
- if checksum == digits[-1]:
33
- return True
34
-
35
- # Check if it's a company IMO number:
36
- checksum = digits[0] * 8 + digits[1] * 6 + digits[2] * 4
37
- checksum += +digits[3] * 2 + digits[4] * 9 + digits[5] * 7
38
- checksum = (11 - (checksum % 11)) % 10
39
- if checksum == digits[-1]:
40
- return True
41
-
42
- return False
43
-
44
- @classmethod
45
- def normalize(cls, text: str) -> Optional[str]:
46
- """Normalize the given string to a valid IMO number."""
47
- match = IMO_RE.search(text)
48
- if match is None:
49
- return None
50
- value = match.group(2).zfill(7)
51
- if cls.is_valid(value):
52
- return f"IMO{value}"
53
- return None
54
-
55
- @classmethod
56
- def format(cls, value: str) -> str:
57
- value = value.replace(" ", "")
58
- if not value.startswith("IMO"):
59
- value = f"IMO{value}"
60
- return value
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes