iporigin 1.1.0__tar.gz → 1.1.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.
- {iporigin-1.1.0 → iporigin-1.1.2}/NOTICE +8 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/PKG-INFO +20 -7
- {iporigin-1.1.0 → iporigin-1.1.2}/README.md +19 -6
- {iporigin-1.1.0 → iporigin-1.1.2}/pyproject.toml +1 -1
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/__init__.py +1 -1
- iporigin-1.1.2/src/iporigin/data/ranges.bin +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/PKG-INFO +20 -7
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/SOURCES.txt +1 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/tests/test_iporigin.py +200 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/tools/build_dataset.py +51 -2
- iporigin-1.1.2/tools/bump_version.py +84 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/tools/sources.py +41 -0
- iporigin-1.1.0/src/iporigin/data/ranges.bin +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/CHANGELOG.md +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/LICENSE +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/MANIFEST.in +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/setup.cfg +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/_data.py +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/cli.py +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/core.py +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/online.py +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin/py.typed +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/dependency_links.txt +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/entry_points.txt +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/requires.txt +0 -0
- {iporigin-1.1.0 → iporigin-1.1.2}/src/iporigin.egg-info/top_level.txt +0 -0
|
@@ -28,6 +28,14 @@ consumed programmatically. No separate permission is needed or claimed.
|
|
|
28
28
|
|
|
29
29
|
CC0 is a public-domain dedication, so these carry no conditions.
|
|
30
30
|
|
|
31
|
+
The AI-company bot slugs probed for outbound crawler coverage
|
|
32
|
+
(OpenAI, Perplexity AI, DuckAssistBot, Apple Intelligence Proxy,
|
|
33
|
+
Meta) come from these same two repositories. The set above is
|
|
34
|
+
what was probed; only Meta adds ranges the disjoint sweep does not
|
|
35
|
+
collapse into an existing bot or vpn label, and the rest overlap
|
|
36
|
+
exactly with GPTBot / PerplexityBot / DuckDuckBot / Apple Private
|
|
37
|
+
Relay and answer under those provider names.
|
|
38
|
+
|
|
31
39
|
|
|
32
40
|
3. Used with the maintainer's permission
|
|
33
41
|
----------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iporigin
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
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
|
|
@@ -91,7 +91,7 @@ origin.is_datacenter # True
|
|
|
91
91
|
| `cdn` | edge infrastructure fronting other people's sites | Cloudflare, Akamai |
|
|
92
92
|
| `vpn` | a consumer VPN or private relay exit | Mullvad, ProtonVPN, Apple Private Relay |
|
|
93
93
|
| `tor` | a Tor exit node | |
|
|
94
|
-
| `bot` | a declared crawler | Googlebot, GPTBot,
|
|
94
|
+
| `bot` | a declared crawler or an AI company's outbound IP | Googlebot, GPTBot, GPTBot (OpenAI's crawler), Meta, ... |
|
|
95
95
|
| `reserved` | private, loopback, link-local, documentation | `127.0.0.1`, `10.0.0.0/8` |
|
|
96
96
|
| `unknown` | in none of our lists — most often a residential ISP | |
|
|
97
97
|
|
|
@@ -185,7 +185,11 @@ and are second-hand by definition:
|
|
|
185
185
|
|
|
186
186
|
Covering Hetzner, OVHcloud, Scaleway, Alibaba Cloud, Leaseweb, UpCloud, IBM
|
|
187
187
|
Cloud, Huawei Cloud, Tencent Cloud, Rackspace, Akamai, Gcore, Mullvad,
|
|
188
|
-
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers.
|
|
188
|
+
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers. AI-company
|
|
189
|
+
outbound ranges (OpenAI, Perplexity AI, DuckAssistBot, Apple Intelligence
|
|
190
|
+
Proxy, Meta) come from these same two repositories. Most of them overlap
|
|
191
|
+
exactly with an existing bot label, so they contribute label diversity
|
|
192
|
+
rather than new bot coverage — only Meta adds distinct segments.
|
|
189
193
|
|
|
190
194
|
Both are CC0, a public-domain dedication, so they carry no conditions.
|
|
191
195
|
|
|
@@ -207,15 +211,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
|
|
|
207
211
|
a source that adds nothing is still another endpoint that can break the
|
|
208
212
|
weekly rebuild.
|
|
209
213
|
|
|
210
|
-
About 454,000 published prefixes collapse into 38,
|
|
211
|
-
(21,
|
|
212
|
-
|
|
214
|
+
About 454,000 published prefixes collapse into roughly 38,400 disjoint
|
|
215
|
+
ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
|
|
216
|
+
addresses. The exact figures move every week with the feeds; the build
|
|
217
|
+
prints them. Rebuild it yourself at any time:
|
|
213
218
|
|
|
214
219
|
```
|
|
215
220
|
python tools/build_dataset.py
|
|
216
221
|
```
|
|
217
222
|
|
|
218
|
-
A GitHub Action re-runs that weekly and
|
|
223
|
+
A GitHub Action re-runs that weekly and commits the result when the ranges
|
|
224
|
+
move, and a second one cuts a patch release on the 6th of each month if the
|
|
225
|
+
dataset changed since the last release — so `pip install --upgrade iporigin`
|
|
226
|
+
is never more than about a month behind the feeds, and a quiet month
|
|
227
|
+
produces no release rather than a version whose only content is a new
|
|
228
|
+
number. The build refuses to replace the committed dataset if it shrinks by
|
|
229
|
+
more than 20% — a feed that starts answering with an empty body looks
|
|
230
|
+
exactly like a provider giving up its address space, and nothing else
|
|
231
|
+
would catch it. Pass `--allow-shrink` when the drop is genuine.
|
|
219
232
|
|
|
220
233
|
### Known gaps
|
|
221
234
|
|
|
@@ -56,7 +56,7 @@ origin.is_datacenter # True
|
|
|
56
56
|
| `cdn` | edge infrastructure fronting other people's sites | Cloudflare, Akamai |
|
|
57
57
|
| `vpn` | a consumer VPN or private relay exit | Mullvad, ProtonVPN, Apple Private Relay |
|
|
58
58
|
| `tor` | a Tor exit node | |
|
|
59
|
-
| `bot` | a declared crawler | Googlebot, GPTBot,
|
|
59
|
+
| `bot` | a declared crawler or an AI company's outbound IP | Googlebot, GPTBot, GPTBot (OpenAI's crawler), Meta, ... |
|
|
60
60
|
| `reserved` | private, loopback, link-local, documentation | `127.0.0.1`, `10.0.0.0/8` |
|
|
61
61
|
| `unknown` | in none of our lists — most often a residential ISP | |
|
|
62
62
|
|
|
@@ -150,7 +150,11 @@ and are second-hand by definition:
|
|
|
150
150
|
|
|
151
151
|
Covering Hetzner, OVHcloud, Scaleway, Alibaba Cloud, Leaseweb, UpCloud, IBM
|
|
152
152
|
Cloud, Huawei Cloud, Tencent Cloud, Rackspace, Akamai, Gcore, Mullvad,
|
|
153
|
-
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers.
|
|
153
|
+
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers. AI-company
|
|
154
|
+
outbound ranges (OpenAI, Perplexity AI, DuckAssistBot, Apple Intelligence
|
|
155
|
+
Proxy, Meta) come from these same two repositories. Most of them overlap
|
|
156
|
+
exactly with an existing bot label, so they contribute label diversity
|
|
157
|
+
rather than new bot coverage — only Meta adds distinct segments.
|
|
154
158
|
|
|
155
159
|
Both are CC0, a public-domain dedication, so they carry no conditions.
|
|
156
160
|
|
|
@@ -172,15 +176,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
|
|
|
172
176
|
a source that adds nothing is still another endpoint that can break the
|
|
173
177
|
weekly rebuild.
|
|
174
178
|
|
|
175
|
-
About 454,000 published prefixes collapse into 38,
|
|
176
|
-
(21,
|
|
177
|
-
|
|
179
|
+
About 454,000 published prefixes collapse into roughly 38,400 disjoint
|
|
180
|
+
ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
|
|
181
|
+
addresses. The exact figures move every week with the feeds; the build
|
|
182
|
+
prints them. Rebuild it yourself at any time:
|
|
178
183
|
|
|
179
184
|
```
|
|
180
185
|
python tools/build_dataset.py
|
|
181
186
|
```
|
|
182
187
|
|
|
183
|
-
A GitHub Action re-runs that weekly and
|
|
188
|
+
A GitHub Action re-runs that weekly and commits the result when the ranges
|
|
189
|
+
move, and a second one cuts a patch release on the 6th of each month if the
|
|
190
|
+
dataset changed since the last release — so `pip install --upgrade iporigin`
|
|
191
|
+
is never more than about a month behind the feeds, and a quiet month
|
|
192
|
+
produces no release rather than a version whose only content is a new
|
|
193
|
+
number. The build refuses to replace the committed dataset if it shrinks by
|
|
194
|
+
more than 20% — a feed that starts answering with an empty body looks
|
|
195
|
+
exactly like a provider giving up its address space, and nothing else
|
|
196
|
+
would catch it. Pass `--allow-shrink` when the drop is genuine.
|
|
184
197
|
|
|
185
198
|
### Known gaps
|
|
186
199
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iporigin"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.2"
|
|
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"
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iporigin
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
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
|
|
@@ -91,7 +91,7 @@ origin.is_datacenter # True
|
|
|
91
91
|
| `cdn` | edge infrastructure fronting other people's sites | Cloudflare, Akamai |
|
|
92
92
|
| `vpn` | a consumer VPN or private relay exit | Mullvad, ProtonVPN, Apple Private Relay |
|
|
93
93
|
| `tor` | a Tor exit node | |
|
|
94
|
-
| `bot` | a declared crawler | Googlebot, GPTBot,
|
|
94
|
+
| `bot` | a declared crawler or an AI company's outbound IP | Googlebot, GPTBot, GPTBot (OpenAI's crawler), Meta, ... |
|
|
95
95
|
| `reserved` | private, loopback, link-local, documentation | `127.0.0.1`, `10.0.0.0/8` |
|
|
96
96
|
| `unknown` | in none of our lists — most often a residential ISP | |
|
|
97
97
|
|
|
@@ -185,7 +185,11 @@ and are second-hand by definition:
|
|
|
185
185
|
|
|
186
186
|
Covering Hetzner, OVHcloud, Scaleway, Alibaba Cloud, Leaseweb, UpCloud, IBM
|
|
187
187
|
Cloud, Huawei Cloud, Tencent Cloud, Rackspace, Akamai, Gcore, Mullvad,
|
|
188
|
-
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers.
|
|
188
|
+
ProtonVPN, Apple Private Relay, Tor, and ten declared crawlers. AI-company
|
|
189
|
+
outbound ranges (OpenAI, Perplexity AI, DuckAssistBot, Apple Intelligence
|
|
190
|
+
Proxy, Meta) come from these same two repositories. Most of them overlap
|
|
191
|
+
exactly with an existing bot label, so they contribute label diversity
|
|
192
|
+
rather than new bot coverage — only Meta adds distinct segments.
|
|
189
193
|
|
|
190
194
|
Both are CC0, a public-domain dedication, so they carry no conditions.
|
|
191
195
|
|
|
@@ -207,15 +211,24 @@ covered. It is a subset of the provider feeds it was itself built from, and
|
|
|
207
211
|
a source that adds nothing is still another endpoint that can break the
|
|
208
212
|
weekly rebuild.
|
|
209
213
|
|
|
210
|
-
About 454,000 published prefixes collapse into 38,
|
|
211
|
-
(21,
|
|
212
|
-
|
|
214
|
+
About 454,000 published prefixes collapse into roughly 38,400 disjoint
|
|
215
|
+
ranges (about 21,900 IPv4 and 16,500 IPv6) covering 287 million IPv4
|
|
216
|
+
addresses. The exact figures move every week with the feeds; the build
|
|
217
|
+
prints them. Rebuild it yourself at any time:
|
|
213
218
|
|
|
214
219
|
```
|
|
215
220
|
python tools/build_dataset.py
|
|
216
221
|
```
|
|
217
222
|
|
|
218
|
-
A GitHub Action re-runs that weekly and
|
|
223
|
+
A GitHub Action re-runs that weekly and commits the result when the ranges
|
|
224
|
+
move, and a second one cuts a patch release on the 6th of each month if the
|
|
225
|
+
dataset changed since the last release — so `pip install --upgrade iporigin`
|
|
226
|
+
is never more than about a month behind the feeds, and a quiet month
|
|
227
|
+
produces no release rather than a version whose only content is a new
|
|
228
|
+
number. The build refuses to replace the committed dataset if it shrinks by
|
|
229
|
+
more than 20% — a feed that starts answering with an empty body looks
|
|
230
|
+
exactly like a provider giving up its address space, and nothing else
|
|
231
|
+
would catch it. Pass `--allow-shrink` when the drop is genuine.
|
|
219
232
|
|
|
220
233
|
### Known gaps
|
|
221
234
|
|
|
@@ -175,6 +175,61 @@ def test_a_known_bot_range_is_classified_as_bot():
|
|
|
175
175
|
pytest.fail("no bot ranges in the dataset")
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
@pytest.mark.parametrize(
|
|
179
|
+
"provider",
|
|
180
|
+
[
|
|
181
|
+
# Updated whenever a new bot provider is added in tools/sources.py.
|
|
182
|
+
# Slugs that returned 404 upstream are omitted from the dataset
|
|
183
|
+
# and skipped here. Slugs whose ranges overlap exactly with an
|
|
184
|
+
# existing bot label are also skipped (they add label diversity
|
|
185
|
+
# without contributing distinct segments to the disjoint sweep).
|
|
186
|
+
"OpenAI",
|
|
187
|
+
"Perplexity AI",
|
|
188
|
+
"DuckAssistBot",
|
|
189
|
+
"Apple Intelligence Proxy",
|
|
190
|
+
"Meta",
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
def test_known_ai_bot_provider_is_classified_as_bot(provider):
|
|
194
|
+
"""One sample per AI-company bot provider, taken from the dataset.
|
|
195
|
+
|
|
196
|
+
Sampled rather than hardcoded because ranges move with the feeds.
|
|
197
|
+
"""
|
|
198
|
+
data = _data.dataset()
|
|
199
|
+
for start, label_idx in zip(data.v4_starts, data.v4_labels):
|
|
200
|
+
if data.labels[label_idx][0] == provider:
|
|
201
|
+
origin = iporigin.classify(str(ipaddress.ip_address(start)))
|
|
202
|
+
assert origin.kind == core.BOT
|
|
203
|
+
assert origin.provider == provider
|
|
204
|
+
assert origin.is_datacenter
|
|
205
|
+
return
|
|
206
|
+
pytest.skip("provider %r has no ranges in this build" % provider)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_AI_BOT_PROVIDERS = [
|
|
210
|
+
"OpenAI",
|
|
211
|
+
"Perplexity AI",
|
|
212
|
+
"DuckAssistBot",
|
|
213
|
+
"Apple Intelligence Proxy",
|
|
214
|
+
"Meta",
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_at_least_one_ai_bot_provider_contributes_ranges():
|
|
219
|
+
"""Catches a future regression where every new AI-bot slug turns
|
|
220
|
+
out to be a strict subset of an existing bot label — the
|
|
221
|
+
parametrized test above would quietly skip every case. At least
|
|
222
|
+
one of the probed providers must add distinct segments.
|
|
223
|
+
"""
|
|
224
|
+
data = _data.dataset()
|
|
225
|
+
bot_providers = {p for p, _ in data.labels if p in _AI_BOT_PROVIDERS}
|
|
226
|
+
assert bot_providers, (
|
|
227
|
+
"no AI-company bot provider contributed distinct ranges "
|
|
228
|
+
"to this build — every probed slug was absorbed into an "
|
|
229
|
+
"existing label"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
178
233
|
def test_a_known_vpn_range_is_classified_as_vpn():
|
|
179
234
|
data = _data.dataset()
|
|
180
235
|
for start, label in zip(data.v4_starts, data.v4_labels):
|
|
@@ -363,3 +418,148 @@ def _stub_api(monkeypatch, payload):
|
|
|
363
418
|
yield Response()
|
|
364
419
|
|
|
365
420
|
monkeypatch.setattr(online.urllib.request, "urlopen", fake_urlopen)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --- the build guard -------------------------------------------------------
|
|
424
|
+
#
|
|
425
|
+
# The weekly refresh commits straight to main, so this check is the only
|
|
426
|
+
# thing standing between a broken upstream feed and a shipped dataset.
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_module():
|
|
430
|
+
import importlib.util
|
|
431
|
+
import pathlib
|
|
432
|
+
|
|
433
|
+
path = pathlib.Path(__file__).parent.parent / "tools" / "build_dataset.py"
|
|
434
|
+
spec = importlib.util.spec_from_file_location("build_dataset", path)
|
|
435
|
+
module = importlib.util.module_from_spec(spec)
|
|
436
|
+
spec.loader.exec_module(module)
|
|
437
|
+
return module
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_a_large_shrink_is_refused():
|
|
441
|
+
build = _build_module()
|
|
442
|
+
assert build.shrink_complaint(30000, 5000) is not None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_a_normal_week_is_allowed():
|
|
446
|
+
"""Feeds move by a few percent all the time."""
|
|
447
|
+
build = _build_module()
|
|
448
|
+
assert build.shrink_complaint(30000, 29000) is None
|
|
449
|
+
assert build.shrink_complaint(30000, 31500) is None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_growth_is_never_refused():
|
|
453
|
+
build = _build_module()
|
|
454
|
+
assert build.shrink_complaint(1000, 100000) is None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_the_first_ever_build_is_allowed():
|
|
458
|
+
"""No committed dataset to compare against."""
|
|
459
|
+
build = _build_module()
|
|
460
|
+
assert build.shrink_complaint(None, 10) is None
|
|
461
|
+
assert build.shrink_complaint(0, 10) is None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_the_threshold_is_the_documented_one():
|
|
465
|
+
build = _build_module()
|
|
466
|
+
assert build.MAX_SHRINK == 0.20
|
|
467
|
+
# Exactly at the limit passes; a hair below does not.
|
|
468
|
+
assert build.shrink_complaint(1000, 800) is None
|
|
469
|
+
assert build.shrink_complaint(1000, 799) is not None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_the_complaint_says_what_to_do():
|
|
473
|
+
build = _build_module()
|
|
474
|
+
message = build.shrink_complaint(30000, 5000)
|
|
475
|
+
assert "--allow-shrink" in message
|
|
476
|
+
assert "5000" in message and "30000" in message
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_existing_range_count_reads_the_committed_dataset():
|
|
480
|
+
build = _build_module()
|
|
481
|
+
count = build.existing_range_count()
|
|
482
|
+
assert count and count > 1000
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_existing_range_count_survives_a_missing_or_corrupt_file(tmp_path, monkeypatch):
|
|
486
|
+
build = _build_module()
|
|
487
|
+
monkeypatch.setattr(build, "OUT", tmp_path / "absent.bin")
|
|
488
|
+
assert build.existing_range_count() is None
|
|
489
|
+
|
|
490
|
+
corrupt = tmp_path / "ranges.bin"
|
|
491
|
+
corrupt.write_bytes(b"nope")
|
|
492
|
+
monkeypatch.setattr(build, "OUT", corrupt)
|
|
493
|
+
assert build.existing_range_count() is None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# --- the release bump ------------------------------------------------------
|
|
497
|
+
#
|
|
498
|
+
# Releases are cut by a scheduled job, so a bump that goes half-way lands on
|
|
499
|
+
# PyPI before anyone looks at it.
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _bump_module():
|
|
503
|
+
import importlib.util
|
|
504
|
+
import pathlib
|
|
505
|
+
|
|
506
|
+
path = pathlib.Path(__file__).parent.parent / "tools" / "bump_version.py"
|
|
507
|
+
spec = importlib.util.spec_from_file_location("bump_version", path)
|
|
508
|
+
module = importlib.util.module_from_spec(spec)
|
|
509
|
+
spec.loader.exec_module(module)
|
|
510
|
+
return module
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_the_two_declared_versions_agree():
|
|
514
|
+
"""pyproject.toml and __init__.py each hold the version separately."""
|
|
515
|
+
bump = _bump_module()
|
|
516
|
+
declared, exported = bump.read_versions(
|
|
517
|
+
bump.PYPROJECT.read_text(), bump.INIT.read_text()
|
|
518
|
+
)
|
|
519
|
+
assert declared == exported == iporigin.__version__
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_patch_bump():
|
|
523
|
+
bump = _bump_module()
|
|
524
|
+
assert bump.next_patch("1.1.0") == "1.1.1"
|
|
525
|
+
assert bump.next_patch("1.1.9") == "1.1.10"
|
|
526
|
+
assert bump.next_patch("0.0.0") == "0.0.1"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_a_non_numeric_version_is_left_alone():
|
|
530
|
+
bump = _bump_module()
|
|
531
|
+
for bad in ("2.0.0rc1", "1.1", "1.1.0.post1", "v1.1.0"):
|
|
532
|
+
with pytest.raises(ValueError):
|
|
533
|
+
bump.next_patch(bad)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_bump_rewrites_both_files():
|
|
537
|
+
bump = _bump_module()
|
|
538
|
+
pyproject, init, new = bump.bump(
|
|
539
|
+
'name = "iporigin"\nversion = "1.2.3"\n', '__version__ = "1.2.3"\n'
|
|
540
|
+
)
|
|
541
|
+
assert new == "1.2.4"
|
|
542
|
+
assert 'version = "1.2.4"' in pyproject
|
|
543
|
+
assert '__version__ = "1.2.4"' in init
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_bump_refuses_when_the_files_disagree():
|
|
547
|
+
bump = _bump_module()
|
|
548
|
+
with pytest.raises(ValueError, match="fix that before releasing"):
|
|
549
|
+
bump.bump('version = "1.2.3"\n', '__version__ = "1.0.0"\n')
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def test_bump_refuses_when_a_declaration_is_missing():
|
|
553
|
+
bump = _bump_module()
|
|
554
|
+
with pytest.raises(ValueError, match="pyproject"):
|
|
555
|
+
bump.read_versions("name = \"iporigin\"\n", '__version__ = "1.0.0"\n')
|
|
556
|
+
with pytest.raises(ValueError, match="__init__"):
|
|
557
|
+
bump.read_versions('version = "1.0.0"\n', "x = 1\n")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_bump_only_touches_the_version_line():
|
|
561
|
+
"""pyproject holds other quoted values; a greedy substitution eats them."""
|
|
562
|
+
bump = _bump_module()
|
|
563
|
+
source = 'version = "1.2.3"\nrequires-python = ">=3.8"\n'
|
|
564
|
+
pyproject, _init, _new = bump.bump(source, '__version__ = "1.2.3"\n')
|
|
565
|
+
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
|
-
|
|
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)
|
|
@@ -14,6 +14,7 @@ import csv
|
|
|
14
14
|
import io
|
|
15
15
|
import json
|
|
16
16
|
import re
|
|
17
|
+
import sys
|
|
17
18
|
import urllib.request
|
|
18
19
|
|
|
19
20
|
USER_AGENT = "iporigin-dataset-builder/1.0 (+https://github.com/Yuix-Networks/iporigin)"
|
|
@@ -214,6 +215,41 @@ def _lord_alfred(slug):
|
|
|
214
215
|
return fetch
|
|
215
216
|
|
|
216
217
|
|
|
218
|
+
def _optional_rezmoss(slug):
|
|
219
|
+
"""Like _rezmoss, but a missing upstream file is logged and skipped,
|
|
220
|
+
not raised. Use for community slugs whose availability is not certain."""
|
|
221
|
+
def fetch():
|
|
222
|
+
for version in (4, 6):
|
|
223
|
+
url = REZMOSS % (slug, slug, version)
|
|
224
|
+
try:
|
|
225
|
+
text = _get_text(url)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
print(" !! %-18s skipped: %s" % (slug, exc), file=sys.stderr)
|
|
228
|
+
continue
|
|
229
|
+
for line in text.splitlines():
|
|
230
|
+
line = line.strip()
|
|
231
|
+
if line and not line.startswith("#") and "/" in line:
|
|
232
|
+
yield line
|
|
233
|
+
return fetch
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _optional_lord_alfred(slug):
|
|
237
|
+
"""Like _lord_alfred, but a missing upstream file is logged and skipped."""
|
|
238
|
+
def fetch():
|
|
239
|
+
for version in (4, 6):
|
|
240
|
+
url = LORD_ALFRED % (slug, version)
|
|
241
|
+
try:
|
|
242
|
+
text = _get_text(url)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
print(" !! %-18s skipped: %s" % (slug, exc), file=sys.stderr)
|
|
245
|
+
continue
|
|
246
|
+
for line in text.splitlines():
|
|
247
|
+
line = line.strip()
|
|
248
|
+
if line and not line.startswith("#") and "/" in line:
|
|
249
|
+
yield line
|
|
250
|
+
return fetch
|
|
251
|
+
|
|
252
|
+
|
|
217
253
|
COMMUNITY = {
|
|
218
254
|
# Hosting with no official feed.
|
|
219
255
|
"Hetzner": ("hosting", _rezmoss("hetzner")),
|
|
@@ -249,6 +285,11 @@ COMMUNITY = {
|
|
|
249
285
|
"Applebot": ("bot", _rezmoss("applebot")),
|
|
250
286
|
"Common Crawl": ("bot", _rezmoss("commoncrawl")),
|
|
251
287
|
"Internet Archive": ("bot", _rezmoss("internetarchive")),
|
|
288
|
+
"OpenAI": ("bot", _optional_lord_alfred("openai")),
|
|
289
|
+
"Perplexity AI": ("bot", _optional_lord_alfred("perplexity")),
|
|
290
|
+
"DuckAssistBot": ("bot", _optional_lord_alfred("duckassistbot")),
|
|
291
|
+
"Apple Intelligence Proxy": ("bot", _optional_lord_alfred("apple-proxy")),
|
|
292
|
+
"Meta": ("bot", _optional_rezmoss("meta")),
|
|
252
293
|
}
|
|
253
294
|
|
|
254
295
|
SOURCES.update(COMMUNITY)
|
|
Binary file
|
|
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
|