Metadata-Version: 2.5
Name: plevin
Version: 0.1.2
Summary: Location, network and abuse information for any IP address in one offline file.
Project-URL: Documentation, https://github.com/tn3w/plevin#readme
Project-URL: Source, https://github.com/tn3w/plevin
Project-URL: Funding, https://www.buymeacoffee.com/tn3w
Author-email: tn3w <tn3w@protonmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: abuse,asn,bgp,datacenter-detection,geoip,geolocation,ip,ip-database,ip-intelligence,ip-lookup,ipv4,ipv6,mmap,network,offline,proxy-detection,reputation,rpki,threat-intelligence,vpn-detection,whois,zstd
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pyzstd>=0.19; python_version < '3.14'
Provides-Extra: country
Requires-Dist: plevin-db-country; extra == 'country'
Provides-Extra: db
Requires-Dist: plevin-db; extra == 'db'
Provides-Extra: full
Requires-Dist: pycountry>=26.2; extra == 'full'
Requires-Dist: tzdata; (sys_platform == 'win32') and extra == 'full'
Provides-Extra: network
Requires-Dist: plevin-db-network; extra == 'network'
Provides-Extra: place
Requires-Dist: plevin-db-place; extra == 'place'
Description-Content-Type: text/markdown

<div align="center">
<a href="https://pypi.org/project/plevin">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tn3w/plevin/master/.github/title-dark.png">
<img src="https://raw.githubusercontent.com/tn3w/plevin/master/.github/title-light.png" width="320" alt="plevin">
</picture>
</a>

**Location, network and abuse information for any IP address in one offline file.**<br>
No API, no rate limit, no lookup leaving the machine.

[![PyPI](https://img.shields.io/pypi/v/plevin?color=1868f2)](https://pypi.org/project/plevin)
[![Python](https://img.shields.io/badge/python-3.10%2B-1868f2)](https://pypi.org/project/plevin)
[![License](https://img.shields.io/badge/license-Apache--2.0-1868f2)](https://github.com/tn3w/plevin/blob/master/LICENSE)
[![Fields](https://img.shields.io/badge/fields-98-6f42c1)](#every-field)
[![Boundaries](https://img.shields.io/badge/boundaries-2.6M-6f42c1)](#data)
[![Warm](https://img.shields.io/badge/warm%20lookups-2M%2Fs-2ea043)](#speed)

</div>

```bash
pip install "plevin[db,full]"
```

```python
import plevin

found = plevin.lookup("1.1.1.1")  # str, int, packed bytes or ipaddress object
```

Always a `Result`, never `None`; `ValueError` for anything that is not an address, and
an integer reads as v6 only above `0xFFFFFFFF`, so `lookup(1)` is `0.0.0.1`.

```python
>>> found.place.city.name, found.place.city.region.name, found.place.country.name
('Brisbane', 'Queensland', 'Australia')

>>> found.network.asn, found.network.operator.brand, found.network.cidr
(13335, 'Cloudflare', '1.1.1.0/24')

>>> exit_node = plevin.lookup("185.220.101.1")
>>> exit_node.abuse.service, exit_node.abuse.risk, exit_node.abuse.is_tor_exit_node
('tor_exit_node', 0.97, True)
```

The database is a separate wheel, found without being given a path. Install one, or
several and the richest wins.

|                                 |         |                                          |
| ------------------------------- | ------- | ---------------------------------------- |
| `pip install "plevin[db]"`      | 16.9 MB | every field                              |
| `pip install "plevin[place]"`   | 6.3 MB  | city, region, postal, coordinates, metro |
| `pip install "plevin[network]"` | 10.3 MB | ASN, operator, routing, abuse            |
| `pip install "plevin[country]"` | 423 KB  | the country code                         |

`PLEVIN_DB=/path/to/plevin.plv` or `plevin.use("plevin.plv")` reads a file of your own
instead; `plevin.Plevin(path)` opens one without touching the module's.

## Every field

<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tn3w/plevin/master/.github/fields-dark.png">
<img src="https://raw.githubusercontent.com/tn3w/plevin/master/.github/fields-light.png" width="840" alt="address to place, network and abuse">
</picture>

`found.place` is where the address is, `found.network` who announces it, `found.abuse`
what has been seen from it. Any of the three is `None` where the build carries none of
it, and every leaf is `None` rather than `""` or `0` where a source says nothing.

```python
Place(
    lat=-27.4675,
    lon=153.0281,
    accuracy=200,
    confidence=36,
    granularity='city',
    city=City(
        id=2174003,
        name='Brisbane',
        ascii='Brisbane',
        country='AU',
        population=2780063,
        elevation=27,
        postal='4000',
        postal_partial=None,
        timezone='Australia/Brisbane',
        type='regional capital',
        capital='region',
        region=Region(id=2152274, code='04', iso='AU-QLD', name='Queensland',
                      type='State'),
        district=District(id=7839562, code='31000', name='Brisbane'),
        metro=None,
    ),
    country=Country(
        code='AU',
        name='Australia',
        official=None,
        common=None,
        iso3='AUS',
        numeric='036',
        flag='🇦🇺',
        european_union=False,
        driving_side='left',
    ),
    time=Time(
        timezone='Australia/Brisbane',
        abbreviation='AEST',
        local='2026-08-13T19:20:00+10:00',
        utc_offset='+10:00',
        is_dst=False,
        dst_start=None,
        dst_end=None,
    ),
)
```

`country` and `time` are derived, not stored: `country` from the two-letter code
through [pycountry](https://pypi.org/project/pycountry), `time` from the zone name
through `zoneinfo`, both only with the `full` extra. Without it the code, the flag, the
EU and driving-side answers and the zone name still come through. `capital` says which
capital the city is, `region.iso` is ISO 3166-2 and `region.code` the GeoNames admin1
number, `postal_partial` is the leading part of `postal` a source could only narrow
that far.

```python
Network(
    asn=13335,
    handle='CLOUDFLARENET',
    prefix=24,
    cidr='1.1.1.0/24',
    start='1.1.1.0',
    end='1.1.1.255',
    rpki='valid',
    roas=1,
    operator=Operator(
        company='Cloudflare, Inc.',
        brand='Cloudflare',
        domain='cloudflare.com',
        website='https://www.cloudflare.com',
        category='content',
        tier=2,
        peering=356,
        scope='Global',
        rir='arin',
        since=2010,
        street='101 Townsend St',
        state='CA',
        postal='94107-1934',
        country='US',
        abuse_email='abuse@cloudflare.com',
        city=City(name='San Francisco', ...),  # a full City, as above
    ),
    carrier=Carrier(user_type='hosting', user_count=19, mcc=None, mnc=None,
                    is_mobile=False),
)
```

`cidr` is the announcement the address falls in, masked out of the address itself, so
`1.1.1.1` and `1.0.0.1` reach one operator through two prefixes. `rpki` is `valid`,
`invalid` or `unknown` and `roas` how many ROAs agree. `brand` drops the legal form and
the words every network carries, so `GOOGLE` and `Google LLC` both read `Google`;
`domain` is the host of `website`, else of `abuse_email`. `tier` is 1 transit-free, 2
has customers, 3 edge; `peering` the exchange count; `category` one of `residential`,
`business`, `hosting`, `education`, `government`, `military`, `cdn`, `content`,
`infrastructure`, `cellular`, `search_engine_spider`, `traveler`, `transit`, `exchange`
or `non-profit`.

```python
>>> plevin.lookup("185.220.101.1").abuse
Abuse(
    name='Tor',
    service='tor_exit_node',
    evidence='measured',
    risk=0.97,
    network_risk=None,
    last_seen_days=1,
    is_anycast=False,
    is_satellite=False,
    is_hosting_provider=True,
    is_proxy=False,
    is_public_proxy=False,
    is_residential_proxy=False,
    is_anonymous_vpn=False,
    is_tor_exit_node=True,
    is_private_relay=False,
    is_anonymous=True,
)
```

`risk` is 0 to 1 for the address, `network_risk` the same for the whole ASN, `None`
where nothing has ever been seen — which is not a risk of zero. `evidence` is
`published`, `measured`, `reported` or `inferred`, strongest first; `service` is
`tor_exit_node`, `private_relay`, `anonymous_vpn`, `residential_proxy` or
`public_proxy`, most specific first. A public proxy on a residential or cellular line
reads as `residential_proxy` with `evidence='inferred'`. The ten booleans are read off
`service` and the carrier's type, never stored.

## What an address says on its own

Answered without the database, so they hold for every address:

```python
>>> found = plevin.lookup("2606:4700::1111")
>>> found.number, found.compressed
(50543257672059871404715951523469725969, '2606:4700::1111')

>>> found.expanded
'2606:4700:0000:0000:0000:0000:0000:1111'

>>> found.arpa
'1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.4.6.0.6.2.ip6.arpa'
```

`is_global` and `is_bogon`, then `is_private`, `is_loopback`, `is_multicast`,
`is_reserved`, `is_link_local`, `is_unique_local`, `is_documentation`, `is_shared`
(100.64/10) and `is_benchmark` (198.18/15), bisected out of the IANA special-purpose
registries.

```python
>>> plevin.lookup("::ffff:8.8.8.8").tunnel, plevin.lookup("::ffff:8.8.8.8").embedded_ipv4
('ipv4-mapped', '8.8.8.8')

>>> plevin.lookup("2002:808:808::1").is_6to4
True
```

`tunnel` is `ipv4-mapped`, `6to4`, `teredo`, `nat64` or `None`, with `embedded_ipv4`
the address it carries; `is_ipv4_mapped`, `is_6to4` and `is_teredo` beside it.

## Good for

- Country routing, pricing and compliance without a third-party call
- Local time and flag before the user types anything
- Bulk log enrichment, at 2M lookups/s on repeats
- Abuse handling and RPKI triage in the same process, no whois or RDAP
- Air-gapped deployments, where no address leaves the process

## Data

|                   |                                     |
| ----------------- | ----------------------------------- |
| v4 boundaries     | 2,182,442, plus 3,832,016 host rows |
| v6 boundaries     | 375,752                             |
| cities            | 76,805 in 3,177 regions             |
| districts, metros | 19,941 and 210                      |
| ASNs, operators   | 86,237 and 80,354                   |
| timezones         | 394                                 |
| abuse records     | 2,147 over 156 feeds                |

Rebuilt monthly from MaxMind GeoLite2, IP2Location LITE, GeoNames, Natural Earth, a
RIPE RIS RIB, RPKI ROAs, the NRO delegations, CAIDA, PeeringDB,
[asn-abuse](https://github.com/tn3w/asn-abuse) and the feeds in
[`builder/data/feeds.json`](https://github.com/tn3w/plevin/blob/master/builder/README.md#sources). `Plevin(path).built` dates your
copy, `.selection` names which fields it carries and `.fields` lists them.

Python 3.10+. No dependencies on 3.14, where `compression.zstd` is in the standard
library; `pyzstd` below it. `pycountry` and, on Windows, `tzdata` come with the `full`
extra.

## Speed

|                     |                                          |
| ------------------- | ---------------------------------------- |
| open                | 2 ms, mmapped and read-only              |
| first answer        | 12 ms                                    |
| repeats             | 2,060,000/s                              |
| uniformly random v4 | 16,000/s, every one a fresh block decode |

Blocks decode on reach and stay decoded, so a real log lands between the two: the
boundary a lookup found, the rows it linked to and the answer itself are all kept.

## Your own file

```python
from plevin import Plevin

with_places = Plevin("dist/plevin.metro-place.plv")
with_places.lookup("1.1.1.1").place.city.name
```

Read-only and memory-mapped, so processes and threads share one file. For the stored
rows without any of the shaping above — dictionaries, codes already read as words —
`Plevin(path).file.row(value, wide)` is the reader underneath.

## Builder

The Rust builder, its sources, the file format and the selection language are in
[`builder/README.md`](https://github.com/tn3w/plevin/blob/master/builder/README.md).

```bash
cd builder && cargo build --release
./target/release/plevin-builder              # dist/plevin.plv, every field
./target/release/plevin-builder place+metro  # dist/plevin.metro-place.plv
```

## Lookup page

[plevin.tn3w.dev](https://plevin.tn3w.dev/) reads the database in the tab
and answers there: no API, and no address of yours sent anywhere except to the service
that tells you your own, and to a resolver for the hostname. It is plain HTML, CSS and
JavaScript in [`site/`](https://github.com/tn3w/plevin/blob/master/site), built and deployed by
[`pages.yml`](https://github.com/tn3w/plevin/blob/master/.github/workflows/pages.yml) whenever a database is released.

The same deployment rehosts every release file with open CORS, which the GitHub release
downloads do not carry:

| | |
| --- | --- |
| `https://plevin.tn3w.dev/db/plevin.plv` | every field, 16.9 MB |
| `https://plevin.tn3w.dev/db/plevin.metro-place.plv` | city, region, postal, coordinates, metro, 6.3 MB |
| `https://plevin.tn3w.dev/db/plevin.abuse-network.plv` | ASN, operator, routing, abuse, 10.3 MB |
| `https://plevin.tn3w.dev/db/plevin.place-country-code.plv` | the country code, 423 KB |
| `https://plevin.tn3w.dev/db/index.json` | the tag and what it carries |

## Cloudflare Worker

[`worker/`](https://github.com/tn3w/plevin/blob/master/worker) is the smallest useful API around the file: it keeps the newest
release in a KV namespace and answers out of an isolate that opened it once.

```bash
cd worker && npm install
npx wrangler kv namespace create PLEVIN   # put the id in wrangler.toml
npx wrangler deploy
curl -X POST https://plevin.<you>.workers.dev/refresh   # then monthly, on a cron
```

```bash
curl https://plevin.<you>.workers.dev/1.1.1.1   # any address
curl https://plevin.<you>.workers.dev/me        # the caller's own
curl https://plevin.<you>.workers.dev/about     # what the file carries
```

`REFRESH_TOKEN` guards the refresh where it is set, and `DATABASE` picks a smaller file
than `plevin.plv`.

## Mini file

[`plevin_mini.py`](https://github.com/tn3w/plevin/blob/master/plevin_mini.py) is the lookup with no package around it.
Drop it beside a `.plv` and it runs.

```bash
python plevin_mini.py plevin.plv 8.8.8.8
```

```python
>>> from plevin_mini import Plevin
>>> Plevin("plevin.plv").lookup("8.8.8.8")["network"]["asn"]
15169
```

Plain dictionaries of the stored rows, codes already read as words, and nothing
derived: no models, no country, no clock, no discovery. 3,600,000 lookups a second
warm. It is linted and type-checked with the package.

## Development

```bash
cd python
uv run pytest          # 168 tests, 100% branch coverage
uv run mypy
uv run basedpyright
uvx ruff check . ../plevin_mini.py --config pyproject.toml
uv build --wheel

cd ../builder && cargo fmt --check && cargo clippy
```

## License

Apache 2.0 for the readers and the builder, see
[LICENSE](https://github.com/tn3w/plevin/blob/master/LICENSE). The database carries
the licenses of the sources it was built from, listed in
[`builder/README.md`](https://github.com/tn3w/plevin/blob/master/builder/README.md#sources).

<!-- brand: Noto Sans 800, wordmark bar #1868f2 place, #6f42c1 network, #2ea043 abuse; #7d8894 address, ink #0b1220 light, #f0f6fc dark -->
