iporigin 1.1.0__tar.gz → 1.1.1__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 (25) hide show
  1. {iporigin-1.1.0 → iporigin-1.1.1}/PKG-INFO +14 -5
  2. {iporigin-1.1.0 → iporigin-1.1.1}/README.md +13 -4
  3. {iporigin-1.1.0 → iporigin-1.1.1}/pyproject.toml +1 -1
  4. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/__init__.py +1 -1
  5. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/data/ranges.bin +0 -0
  6. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/PKG-INFO +14 -5
  7. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/SOURCES.txt +1 -0
  8. {iporigin-1.1.0 → iporigin-1.1.1}/tests/test_iporigin.py +145 -0
  9. {iporigin-1.1.0 → iporigin-1.1.1}/tools/build_dataset.py +51 -2
  10. iporigin-1.1.1/tools/bump_version.py +84 -0
  11. {iporigin-1.1.0 → iporigin-1.1.1}/CHANGELOG.md +0 -0
  12. {iporigin-1.1.0 → iporigin-1.1.1}/LICENSE +0 -0
  13. {iporigin-1.1.0 → iporigin-1.1.1}/MANIFEST.in +0 -0
  14. {iporigin-1.1.0 → iporigin-1.1.1}/NOTICE +0 -0
  15. {iporigin-1.1.0 → iporigin-1.1.1}/setup.cfg +0 -0
  16. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/_data.py +0 -0
  17. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/cli.py +0 -0
  18. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/core.py +0 -0
  19. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/online.py +0 -0
  20. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin/py.typed +0 -0
  21. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/dependency_links.txt +0 -0
  22. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/entry_points.txt +0 -0
  23. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/requires.txt +0 -0
  24. {iporigin-1.1.0 → iporigin-1.1.1}/src/iporigin.egg-info/top_level.txt +0 -0
  25. {iporigin-1.1.0 → iporigin-1.1.1}/tools/sources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iporigin
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Is this IP a datacenter, VPN, Tor exit or a home connection? Offline lookup, no API key.
5
5
  Author-email: Yuix Networks <info@yuix.org>
6
6
  License: MIT
@@ -207,15 +207,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
207
207
  a source that adds nothing is still another endpoint that can break the
208
208
  weekly rebuild.
209
209
 
210
- About 454,000 published prefixes collapse into 38,379 disjoint ranges
211
- (21,901 IPv4, 16,478 IPv6) covering 287 million IPv4 addresses. Rebuild it
212
- yourself at any time:
210
+ About 454,000 published prefixes collapse into roughly 38,400 disjoint
211
+ ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
212
+ addresses. The exact figures move every week with the feeds; the build
213
+ prints them. Rebuild it yourself at any time:
213
214
 
214
215
  ```
215
216
  python tools/build_dataset.py
216
217
  ```
217
218
 
218
- A GitHub Action re-runs that weekly and opens a PR when the ranges move.
219
+ A GitHub Action re-runs that weekly and commits the result when the ranges
220
+ move, and a second one cuts a patch release on the 6th of each month if the
221
+ dataset changed since the last release — so `pip install --upgrade iporigin`
222
+ is never more than about a month behind the feeds, and a quiet month
223
+ produces no release rather than a version whose only content is a new
224
+ number. The build refuses to replace the committed dataset if it shrinks by
225
+ more than 20% — a feed that starts answering with an empty body looks
226
+ exactly like a provider giving up its address space, and nothing else
227
+ would catch it. Pass `--allow-shrink` when the drop is genuine.
219
228
 
220
229
  ### Known gaps
221
230
 
@@ -172,15 +172,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
172
172
  a source that adds nothing is still another endpoint that can break the
173
173
  weekly rebuild.
174
174
 
175
- About 454,000 published prefixes collapse into 38,379 disjoint ranges
176
- (21,901 IPv4, 16,478 IPv6) covering 287 million IPv4 addresses. Rebuild it
177
- yourself at any time:
175
+ About 454,000 published prefixes collapse into roughly 38,400 disjoint
176
+ ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
177
+ addresses. The exact figures move every week with the feeds; the build
178
+ prints them. Rebuild it yourself at any time:
178
179
 
179
180
  ```
180
181
  python tools/build_dataset.py
181
182
  ```
182
183
 
183
- A GitHub Action re-runs that weekly and opens a PR when the ranges move.
184
+ A GitHub Action re-runs that weekly and commits the result when the ranges
185
+ move, and a second one cuts a patch release on the 6th of each month if the
186
+ dataset changed since the last release — so `pip install --upgrade iporigin`
187
+ is never more than about a month behind the feeds, and a quiet month
188
+ produces no release rather than a version whose only content is a new
189
+ number. The build refuses to replace the committed dataset if it shrinks by
190
+ more than 20% — a feed that starts answering with an empty body looks
191
+ exactly like a provider giving up its address space, and nothing else
192
+ would catch it. Pass `--allow-shrink` when the drop is genuine.
184
193
 
185
194
  ### Known gaps
186
195
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "iporigin"
7
- version = "1.1.0"
7
+ version = "1.1.1"
8
8
  description = "Is this IP a datacenter, VPN, Tor exit or a home connection? Offline lookup, no API key."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -29,7 +29,7 @@ from .core import (
29
29
  is_datacenter,
30
30
  )
31
31
 
32
- __version__ = "1.1.0"
32
+ __version__ = "1.1.1"
33
33
 
34
34
  __all__ = [
35
35
  "ANONYMIZER_KINDS",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iporigin
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Is this IP a datacenter, VPN, Tor exit or a home connection? Offline lookup, no API key.
5
5
  Author-email: Yuix Networks <info@yuix.org>
6
6
  License: MIT
@@ -207,15 +207,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
207
207
  a source that adds nothing is still another endpoint that can break the
208
208
  weekly rebuild.
209
209
 
210
- About 454,000 published prefixes collapse into 38,379 disjoint ranges
211
- (21,901 IPv4, 16,478 IPv6) covering 287 million IPv4 addresses. Rebuild it
212
- yourself at any time:
210
+ About 454,000 published prefixes collapse into roughly 38,400 disjoint
211
+ ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
212
+ addresses. The exact figures move every week with the feeds; the build
213
+ prints them. Rebuild it yourself at any time:
213
214
 
214
215
  ```
215
216
  python tools/build_dataset.py
216
217
  ```
217
218
 
218
- A GitHub Action re-runs that weekly and opens a PR when the ranges move.
219
+ A GitHub Action re-runs that weekly and commits the result when the ranges
220
+ move, and a second one cuts a patch release on the 6th of each month if the
221
+ dataset changed since the last release — so `pip install --upgrade iporigin`
222
+ is never more than about a month behind the feeds, and a quiet month
223
+ produces no release rather than a version whose only content is a new
224
+ number. The build refuses to replace the committed dataset if it shrinks by
225
+ more than 20% — a feed that starts answering with an empty body looks
226
+ exactly like a provider giving up its address space, and nothing else
227
+ would catch it. Pass `--allow-shrink` when the drop is genuine.
219
228
 
220
229
  ### Known gaps
221
230
 
@@ -19,4 +19,5 @@ src/iporigin.egg-info/top_level.txt
19
19
  src/iporigin/data/ranges.bin
20
20
  tests/test_iporigin.py
21
21
  tools/build_dataset.py
22
+ tools/bump_version.py
22
23
  tools/sources.py
@@ -363,3 +363,148 @@ def _stub_api(monkeypatch, payload):
363
363
  yield Response()
364
364
 
365
365
  monkeypatch.setattr(online.urllib.request, "urlopen", fake_urlopen)
366
+
367
+
368
+ # --- the build guard -------------------------------------------------------
369
+ #
370
+ # The weekly refresh commits straight to main, so this check is the only
371
+ # thing standing between a broken upstream feed and a shipped dataset.
372
+
373
+
374
+ def _build_module():
375
+ import importlib.util
376
+ import pathlib
377
+
378
+ path = pathlib.Path(__file__).parent.parent / "tools" / "build_dataset.py"
379
+ spec = importlib.util.spec_from_file_location("build_dataset", path)
380
+ module = importlib.util.module_from_spec(spec)
381
+ spec.loader.exec_module(module)
382
+ return module
383
+
384
+
385
+ def test_a_large_shrink_is_refused():
386
+ build = _build_module()
387
+ assert build.shrink_complaint(30000, 5000) is not None
388
+
389
+
390
+ def test_a_normal_week_is_allowed():
391
+ """Feeds move by a few percent all the time."""
392
+ build = _build_module()
393
+ assert build.shrink_complaint(30000, 29000) is None
394
+ assert build.shrink_complaint(30000, 31500) is None
395
+
396
+
397
+ def test_growth_is_never_refused():
398
+ build = _build_module()
399
+ assert build.shrink_complaint(1000, 100000) is None
400
+
401
+
402
+ def test_the_first_ever_build_is_allowed():
403
+ """No committed dataset to compare against."""
404
+ build = _build_module()
405
+ assert build.shrink_complaint(None, 10) is None
406
+ assert build.shrink_complaint(0, 10) is None
407
+
408
+
409
+ def test_the_threshold_is_the_documented_one():
410
+ build = _build_module()
411
+ assert build.MAX_SHRINK == 0.20
412
+ # Exactly at the limit passes; a hair below does not.
413
+ assert build.shrink_complaint(1000, 800) is None
414
+ assert build.shrink_complaint(1000, 799) is not None
415
+
416
+
417
+ def test_the_complaint_says_what_to_do():
418
+ build = _build_module()
419
+ message = build.shrink_complaint(30000, 5000)
420
+ assert "--allow-shrink" in message
421
+ assert "5000" in message and "30000" in message
422
+
423
+
424
+ def test_existing_range_count_reads_the_committed_dataset():
425
+ build = _build_module()
426
+ count = build.existing_range_count()
427
+ assert count and count > 1000
428
+
429
+
430
+ def test_existing_range_count_survives_a_missing_or_corrupt_file(tmp_path, monkeypatch):
431
+ build = _build_module()
432
+ monkeypatch.setattr(build, "OUT", tmp_path / "absent.bin")
433
+ assert build.existing_range_count() is None
434
+
435
+ corrupt = tmp_path / "ranges.bin"
436
+ corrupt.write_bytes(b"nope")
437
+ monkeypatch.setattr(build, "OUT", corrupt)
438
+ assert build.existing_range_count() is None
439
+
440
+
441
+ # --- the release bump ------------------------------------------------------
442
+ #
443
+ # Releases are cut by a scheduled job, so a bump that goes half-way lands on
444
+ # PyPI before anyone looks at it.
445
+
446
+
447
+ def _bump_module():
448
+ import importlib.util
449
+ import pathlib
450
+
451
+ path = pathlib.Path(__file__).parent.parent / "tools" / "bump_version.py"
452
+ spec = importlib.util.spec_from_file_location("bump_version", path)
453
+ module = importlib.util.module_from_spec(spec)
454
+ spec.loader.exec_module(module)
455
+ return module
456
+
457
+
458
+ def test_the_two_declared_versions_agree():
459
+ """pyproject.toml and __init__.py each hold the version separately."""
460
+ bump = _bump_module()
461
+ declared, exported = bump.read_versions(
462
+ bump.PYPROJECT.read_text(), bump.INIT.read_text()
463
+ )
464
+ assert declared == exported == iporigin.__version__
465
+
466
+
467
+ def test_patch_bump():
468
+ bump = _bump_module()
469
+ assert bump.next_patch("1.1.0") == "1.1.1"
470
+ assert bump.next_patch("1.1.9") == "1.1.10"
471
+ assert bump.next_patch("0.0.0") == "0.0.1"
472
+
473
+
474
+ def test_a_non_numeric_version_is_left_alone():
475
+ bump = _bump_module()
476
+ for bad in ("2.0.0rc1", "1.1", "1.1.0.post1", "v1.1.0"):
477
+ with pytest.raises(ValueError):
478
+ bump.next_patch(bad)
479
+
480
+
481
+ def test_bump_rewrites_both_files():
482
+ bump = _bump_module()
483
+ pyproject, init, new = bump.bump(
484
+ 'name = "iporigin"\nversion = "1.2.3"\n', '__version__ = "1.2.3"\n'
485
+ )
486
+ assert new == "1.2.4"
487
+ assert 'version = "1.2.4"' in pyproject
488
+ assert '__version__ = "1.2.4"' in init
489
+
490
+
491
+ def test_bump_refuses_when_the_files_disagree():
492
+ bump = _bump_module()
493
+ with pytest.raises(ValueError, match="fix that before releasing"):
494
+ bump.bump('version = "1.2.3"\n', '__version__ = "1.0.0"\n')
495
+
496
+
497
+ def test_bump_refuses_when_a_declaration_is_missing():
498
+ bump = _bump_module()
499
+ with pytest.raises(ValueError, match="pyproject"):
500
+ bump.read_versions("name = \"iporigin\"\n", '__version__ = "1.0.0"\n')
501
+ with pytest.raises(ValueError, match="__init__"):
502
+ bump.read_versions('version = "1.0.0"\n', "x = 1\n")
503
+
504
+
505
+ def test_bump_only_touches_the_version_line():
506
+ """pyproject holds other quoted values; a greedy substitution eats them."""
507
+ bump = _bump_module()
508
+ source = 'version = "1.2.3"\nrequires-python = ">=3.8"\n'
509
+ pyproject, _init, _new = bump.bump(source, '__version__ = "1.2.3"\n')
510
+ assert 'requires-python = ">=3.8"' in pyproject
@@ -155,7 +155,48 @@ def pack(v4, v6):
155
155
  return bytes(out)
156
156
 
157
157
 
158
- def main():
158
+ #: Refuse to replace the dataset if it shrinks by more than this. A source
159
+ #: that starts answering 200 with an empty body, or quietly changes its JSON
160
+ #: shape, looks exactly like a provider giving up its address space — and
161
+ #: nothing else in the pipeline would notice.
162
+ MAX_SHRINK = 0.20
163
+
164
+
165
+ def existing_range_count():
166
+ """How many ranges the committed dataset holds, or None if there is none."""
167
+ if not OUT.exists():
168
+ return None
169
+ try:
170
+ blob = OUT.read_bytes()
171
+ magic, _version, _built, _labels, n_v4, n_v6 = struct.unpack_from("<8sHQHII", blob, 0)
172
+ except Exception:
173
+ return None
174
+ if magic != MAGIC:
175
+ return None
176
+ return n_v4 + n_v6
177
+
178
+
179
+ def shrink_complaint(previous, current, max_shrink=None):
180
+ """Why this build must not replace the committed one, or None.
181
+
182
+ A source that starts answering 200 with an empty body, or quietly
183
+ changes its JSON shape, looks exactly like a provider giving up its
184
+ address space. Nothing else in the pipeline notices, and since the
185
+ refresh now commits straight to main there is no review that would.
186
+ """
187
+ limit = MAX_SHRINK if max_shrink is None else max_shrink
188
+ if not previous or current >= previous * (1 - limit):
189
+ return None
190
+ return (
191
+ "Refusing to write: %d ranges is %.0f%% below the %d already "
192
+ "committed. Either several sources failed at once, or one changed "
193
+ "shape and is now parsing to nothing. Check the per-source counts "
194
+ "above, then override with --allow-shrink if it is genuine."
195
+ % (current, 100 * (1 - current / previous), previous)
196
+ )
197
+
198
+
199
+ def main(allow_shrink=False):
159
200
  print("Fetching provider feeds...")
160
201
  v4, v6 = collect()
161
202
  if not v4:
@@ -169,6 +210,14 @@ def main():
169
210
  v4, v6 = flatten(v4), flatten(v6)
170
211
  blob = pack(v4, v6)
171
212
 
213
+ # The last line of defence now that nothing downstream reviews this.
214
+ complaint = None if allow_shrink else shrink_complaint(
215
+ existing_range_count(), len(v4) + len(v6)
216
+ )
217
+ if complaint:
218
+ print(complaint, file=sys.stderr)
219
+ return 1
220
+
172
221
  OUT.parent.mkdir(parents=True, exist_ok=True)
173
222
  OUT.write_bytes(blob)
174
223
 
@@ -179,4 +228,4 @@ def main():
179
228
 
180
229
 
181
230
  if __name__ == "__main__":
182
- raise SystemExit(main())
231
+ raise SystemExit(main(allow_shrink="--allow-shrink" in sys.argv))
@@ -0,0 +1,84 @@
1
+ """Bump the patch version, in both of the places that hold it.
2
+
3
+ The version lives in pyproject.toml (what PyPI sees) and in
4
+ src/iporigin/__init__.py (what `iporigin --version` and any caller sees).
5
+ Nothing enforces that they agree, and a release that bumps one of them
6
+ ships a package that lies about itself — so this refuses to touch either
7
+ unless they start out identical.
8
+ """
9
+ import pathlib
10
+ import re
11
+ import sys
12
+
13
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
14
+ PYPROJECT = ROOT / "pyproject.toml"
15
+ INIT = ROOT / "src" / "iporigin" / "__init__.py"
16
+
17
+ PYPROJECT_RE = re.compile(r'^version = "([^"]+)"', re.M)
18
+ INIT_RE = re.compile(r'^__version__ = "([^"]+)"', re.M)
19
+
20
+
21
+ def read_versions(pyproject_text, init_text):
22
+ """The declared version in each file, as a pair."""
23
+ found = []
24
+ for pattern, text, where in (
25
+ (PYPROJECT_RE, pyproject_text, "pyproject.toml"),
26
+ (INIT_RE, init_text, "__init__.py"),
27
+ ):
28
+ match = pattern.search(text)
29
+ if not match:
30
+ raise ValueError("no version declaration found in %s" % where)
31
+ found.append(match.group(1))
32
+ return tuple(found)
33
+
34
+
35
+ def next_patch(version):
36
+ """1.1.0 -> 1.1.1. Refuses anything that is not three plain numbers,
37
+ because guessing at what comes after 2.0.0rc1 is not this script's job."""
38
+ parts = version.split(".")
39
+ if len(parts) != 3 or not all(p.isdigit() for p in parts):
40
+ raise ValueError(
41
+ "%r is not a plain major.minor.patch version; bump it by hand" % version
42
+ )
43
+ major, minor, patch = parts
44
+ return "%s.%s.%d" % (major, minor, int(patch) + 1)
45
+
46
+
47
+ def bump(pyproject_text, init_text):
48
+ """Both files' new contents, plus the version they now declare."""
49
+ current_pyproject, current_init = read_versions(pyproject_text, init_text)
50
+ if current_pyproject != current_init:
51
+ raise ValueError(
52
+ "pyproject.toml says %s but __init__.py says %s; fix that before "
53
+ "releasing" % (current_pyproject, current_init)
54
+ )
55
+ new = next_patch(current_pyproject)
56
+ return (
57
+ PYPROJECT_RE.sub('version = "%s"' % new, pyproject_text, count=1),
58
+ INIT_RE.sub('__version__ = "%s"' % new, init_text, count=1),
59
+ new,
60
+ )
61
+
62
+
63
+ def main(argv):
64
+ pyproject_text = PYPROJECT.read_text()
65
+ init_text = INIT.read_text()
66
+
67
+ if "--current" in argv:
68
+ print(read_versions(pyproject_text, init_text)[0])
69
+ return 0
70
+
71
+ new_pyproject, new_init, new = bump(pyproject_text, init_text)
72
+ if "--dry-run" not in argv:
73
+ PYPROJECT.write_text(new_pyproject)
74
+ INIT.write_text(new_init)
75
+ print(new)
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ try:
81
+ raise SystemExit(main(sys.argv[1:]))
82
+ except ValueError as exc:
83
+ print("error: %s" % exc, file=sys.stderr)
84
+ raise SystemExit(1)
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