librats for Python
Peer-to-peer networking for Python. Your programs connect to each other directly, with no server in between, over encrypted connections. They find each other on the local network or across the internet, get through home routers, and exchange messages, JSON values and files.
You could build with it:
- a chat or messenger with no server
- an app whose copies on several computers keep each other in sync
- a tool that finds other copies of itself on the local network
- file sharing between machines
- a Python service that talks to apps built on librats in other languages: the same library exists for C++, C, Node.js, Java/Android, React Native and iOS
Things to know before you pick it:
- The networking is native. Sockets, encryption and delivery run in the C++ library on its own threads. Python only sees the events: a message arrived, a peer connected. There is nothing to compile, the library ships inside the package.
- It speaks its own protocol. It uses the BitTorrent DHT to find peers, but it connects only to other librats programs, not to WebRTC, libp2p or BitTorrent clients.
Documentation
- This page is the guide: installation, the concepts, and every feature with an example.
- The docstrings are the full API reference, every method and option with
its default:
help(librats_py.RatsNode), or hover in your editor. - Examples: a chat, pub/sub, file transfer. They are installed with the package.
- librats.com and the main README describe the library as a whole, how it works inside, and the other languages.
- Questions and bugs: GitHub issues.
On this page:
- Installation
- Hello, world and a first real node
- Example: a chat with no server
- Connecting to peers and who can connect
- Sending messages, including bulk and live audio/video
- More features: JSON, pub/sub, file transfer, NAT traversal, reconnection
- Configuration, lifecycle, errors, logging
Installation
pip install librats
The package is called librats, the module you import is librats_py:
from librats_py import RatsNode
It needs Python 3.8 or newer. The package ships the compiled library for:
- Windows x64
- Linux x86_64 and aarch64 with glibc 2.28 or newer (Debian 10, Ubuntu 20.04, RHEL 8 and later)
- macOS 11 or newer, Intel and Apple Silicon
Nothing else is needed. On any other platform pip finds no matching package; see building from source. The hello world below is a quick check that the install worked.
Hello, world
Two nodes in one script, so it runs on one machine with nothing else to set up:
import threading
from librats_py import RatsNode, LogLevel, set_log_level
set_log_level(LogLevel.WARN) # librats logs its startup at INFO; keep the output short
alice = RatsNode()
bob = RatsNode()
done = threading.Event()
def on_greeting(peer_id, data):
print(f'bob got "{data.decode()}" from {peer_id[:8]}…')
done.set()
def on_connected(peer_id):
print(f"alice is connected to {peer_id[:8]}…")
alice.send(peer_id, "greeting", b"hello, bob")
bob.on("greeting", on_greeting)
alice.on_peer_connected(on_connected)
alice.start()
bob.start()
alice.connect("127.0.0.1", bob.listen_port)
done.wait(10) # handlers run on librats' own thread; wait here for them
alice.destroy()
bob.destroy()
alice is connected to b3eac42c…
bob got "hello, bob" from 58dd7707…
What happened: each node got a peer id, a long hex string that is its
identity. start() opened a port, picked by the system because none was given.
Alice dialed Bob's port; the two exchanged keys, and once each knew who the other
is, on_peer_connected fired. Alice then sent a message on a channel named
"greeting", and Bob's handler for that channel received it.
The handlers ran on a thread of librats, not on the main thread, which is why
the script waits on an Event before it destroys the nodes.
In a real app each node is a separate program, usually on its own computer, and the nodes find each other by themselves instead of being told an address.
A first real node
from librats_py import RatsNode
node = RatsNode(8080, data_dir="./state")
node.on_peer_connected(lambda peer_id: node.send(peer_id, "chat", b"hello"))
node.on("chat", lambda peer_id, data: print(peer_id[:8], data.decode()))
node.enable_mdns() # find other nodes on the local network
with node: # destroys the node when the block ends
node.start()
print("I am", node.local_id, "on port", node.listen_port)
input("Enter to quit\n")
Run this script on two computers in the same network. The nodes find each other,
connect, and greet each other. To connect by hand instead, call
node.connect("192.168.1.10", 8080) after start().
A few things to know from the start:
- A node is identified by its peer id, a 64-character hex string. The id is
the node's public key, so nobody else can claim it. Pass
data_dirto keep the same identity across restarts; without it the node gets a new id every run. protocolseparates applications. It defaults to"librats/1.0". It is bound into the handshake, so nodes with different protocols cannot connect to each other, and the DHT only finds nodes with the same one. Give your app its own, such asRatsNode(protocol="my-app/1.0"). It is not a password, though: see who can connect.- Set the node up before
start(). Features (enable_mdns(),enable_dht(), …) and the node's own handlers (on,on_peer_connected, the file handlers, …) can only be added before the node starts; afterwards they raiseRatsAlreadyStartedError. JSON handlers and pub/sub subscriptions are the exception and can come and go at any time. - Handlers run on librats' thread. That thread also does the networking, so
while a handler runs, the node's traffic waits. Keep handlers short; for slow
work, put the message on a
queue.Queueand handle it on a thread of your own. An exception raised in a handler is printed to stderr and goes no further. - The program has to keep running. The node works only while the program
does: when the main thread ends, the nodes are closed. Wait on something:
input(), a loop, or anEventas in the hello world.
Example: a chat with no server
Everyone who runs this script with the same room name is in the same chat.
Nodes on one network find each other over mDNS, and nodes elsewhere through the
DHT. /connect dials someone directly.
import sys
from librats_py import RatsNode, RatsError, LogLevel, set_log_level
name = sys.argv[1] if len(sys.argv) > 1 else "anon"
room = sys.argv[2] if len(sys.argv) > 2 else "lobby"
port = int(sys.argv[3]) if len(sys.argv) > 3 else 0
set_log_level(LogLevel.ERROR) # library logs would interleave with the chat
# The protocol is bound into the handshake: only nodes in the same room can
# connect, and the DHT, which searches by protocol, looks for exactly them.
node = RatsNode(port, protocol=f"rats-chat/{room}")
node.enable_mdns() # the local network
node.enable_dht() # the internet
node.enable_json() # messages are {"name": ..., "text": ...} objects
def short(peer_id):
return peer_id[:8]
def on_say(peer_id, msg):
# Anything can arrive from a peer, so check the shape before using it.
if not isinstance(msg, dict) or not isinstance(msg.get("text"), str):
return
print(f"{str(msg.get('name'))[:32]}: {msg['text']}", flush=True)
node.on_peer_connected(lambda peer_id: print(f"* {short(peer_id)} joined", flush=True))
node.on_peer_disconnected(
lambda peer_id, reason: print(f"* {short(peer_id)} left ({reason.name.lower()})", flush=True))
node.on_json("say", on_say)
with node: # destroys the node on the way out
node.start()
print(f'room "{room}", port {node.listen_port}, id {short(node.local_id)}')
print("/connect <host> <port>, /peers, /quit\n", flush=True)
try:
for line in sys.stdin: # ends on Ctrl+D (Ctrl+Z, Enter on Windows)
command, *args = line.split() or [""]
try:
if command == "/connect":
node.connect(args[0], int(args[1]))
elif command == "/peers":
print(" ".join(map(short, node.peer_ids)) or "nobody yet")
elif command == "/quit":
break
elif line.strip():
node.broadcast_json("say", {"name": name, "text": line.strip()})
except (RatsError, ValueError, IndexError) as e:
print(f"! {e}")
except KeyboardInterrupt: # Ctrl+C
pass
The script is installed with the package, so you can run it right away, in two terminals, on one computer or on two in the same network:
python -m librats_py.examples.chat alice
python -m librats_py.examples.chat bob
room "lobby", port 47011, id c875ccab room "lobby", port 56796, id 04655af7
/connect <host> <port>, /peers, /quit /connect <host> <port>, /peers, /quit
* 04655af7 joined * c875ccab joined
bob: hello from bob hello from bob
привет 👋 alice: привет 👋
What happens and where the limits are:
- On the same network the nodes find each other by themselves, over mDNS,
usually within a few seconds. Where multicast does not get through (some
guest Wi-Fi, VPNs),
/connect <host> <port>dials directly; the port is the one the script prints at start. - Through the internet, the DHT needs time and a reachable node. A new node
first joins the DHT, then announces itself and searches every 30 seconds, so
finding each other takes from tens of seconds to a few minutes. Then one side
has to accept the connection: a public address, a forwarded port, or a router
that agrees to forward one (
node.enable_port_mapping()asks it over UPnP and NAT-PMP). Two nodes behind NAT with nobody in common to help them cannot connect, as described in NAT traversal. - There is no history. A message reaches whoever is connected at the moment.
broadcast_jsonsends it to every connected peer and the peers do not pass it on, so a large room needs every member connected to every other.gossipsub_chat.pyis a chat over pub/sub, where messages also spread through intermediate peers.
Connecting to peers
node.connect("example.com", 8080) # a name or an IP address
node.disconnect(peer_id) # RatsNoSuchPeerError if it was not connected
node.peer_count # how many peers are connected
node.peer_ids # their ids
node.peer_info(peer_id) # PeerInfo(transport, transports, direction, address), or None
node.max_peers = 50 # cap on peers, enforced on inbound; 0 is no cap
connect() returns immediately. A connection that succeeds arrives at
on_peer_connected. One that fails arrives at on_dial_failed, but only for an
IP address: a dial by host name fails silently. on_peer_disconnected reports
why a peer left as a CloseReason: PEER_CLOSED, IDLE_TIMEOUT,
SLOW_CONSUMER, and so on.
Each node listens on one port over both UDP and TCP. A dial tries UDP first and
also tries TCP if UDP does not succeed within 1.2 seconds.
node.peer_info(peer_id).transport shows which one was used.
What the node knows about itself: node.local_id, node.listen_port (the real
port when 0 was asked for), node.observed_addresses (its own addresses as
connected peers see them, which is how a node behind NAT learns its public
address) and node.nat_mapping (see NAT traversal).
There are three ways for nodes to find each other without knowing an address, and they can be combined:
| How it finds peers | |
|---|---|
enable_mdns() |
multicast on the local network |
enable_dht(dht_port=0, discovery_key=None) |
the BitTorrent mainline DHT, worldwide. By default it looks for nodes with the same protocol; dht_set_keys([...]) replaces the keys at any time, also while running |
enable_peer_exchange() |
connected peers tell each other about the peers they hold, so one contact is enough to find the rest |
Who can connect
Every connection is encrypted and authenticated with the Noise protocol. When
on_peer_connected reports a peer id, the other side has proved it holds that
id's private key, and nobody in between can read or change the traffic.
What librats does not decide is whether you want that peer. Any node with the
same protocol string can connect to yours, and the string is not secret: it is
in your app, and the DHT lookup is derived from it. If only certain peers may
talk to your node, keep a list of the ids you trust, which stay fixed when the
peers use data_dir, and drop everyone else:
trusted = {"3f2a…", "9c41…"} # full 64-character ids
def on_connected(peer_id):
if peer_id not in trusted:
node.disconnect(peer_id)
node.on_peer_connected(on_connected)
The same goes for what peers send: a handler gets whatever bytes the peer chose to send, so check them before using them.
Sending messages
Messages travel on named channels: a name you choose, such as "chat" or
"state". You send bytes (a str raises TypeError, so encode it first),
and the handler gets bytes.
import json
node.on("state", lambda peer_id, data: apply(json.loads(data)))
node.send(peer_id, "state", json.dumps(state).encode()) # one peer; RatsNoSuchPeerError if not connected
node.broadcast("state", json.dumps(state).encode()) # every connected peer
What a channel guarantees:
- Reliable and in order. Everything a node sends to a peer arrives in the order it was sent, across all channels (live messages aside).
- Only while the connection lasts. If the peer disconnects, what was still queued for it is lost. There is no store-and-forward: a peer that is offline does not get the message later.
- Up to almost 64 MiB per message. A larger message is not delivered:
send()still returns, and a warning in the log says it was refused. Split big data into pieces, or use file transfer. - Only to a peer that listens. A message on a channel the receiver has no handler for is dropped silently.
Channel names are hashed to 16-bit ids on the wire. Two different names can in principle get the same id; if two of the names your node listens on do, a warning is logged when the second handler is registered.
When you send a lot
send() returns once the message is in the queue, before it reaches the peer. If
you put messages in faster than the connection carries them, the queue grows,
and past its limit (8 MiB by default, send_queue_limit) the peer is
disconnected with CloseReason.SLOW_CONSUMER. To avoid that, check
peer_writable(peer_id) after each send, and once it returns False, wait for
on_peer_writable:
from collections import deque
chunks = deque(...)
def pump(peer_id):
while chunks:
node.send(peer_id, "bulk", chunks.popleft())
if not node.peer_writable(peer_id):
return # continue when on_peer_writable fires
node.on_peer_writable(pump)
For files, use file transfer instead, which already does this.
Live messages: audio and video
lifetime_ms makes a message live: it arrives within that time or not at
all, never partly and never twice, and it is not ordered with other messages.
An old video frame is useless, and this keeps a stale one from delaying the
next.
node.send(peer_id, "video", frame, lifetime_ms=150) # delivered within 150 ms or dropped
The longest lifetime is LIVE_MAX_LIFETIME_MS (30 s) and the largest payload is
LIVE_MAX_PAYLOAD (4 MiB). The receiver needs nothing special. An encoder can
adapt its bitrate to path_stats(peer_id), which reports the bandwidth
estimate, round trip, loss, queued bytes and how many live messages expired.
The on_peer_path handler receives the same figures every 100 ms.
More features
Each of these is off until enabled, and is enabled before start():
| Feature | Enable | Then use |
|---|---|---|
| DHT discovery | enable_dht(dht_port=0, discovery_key=None) |
dht_set_keys([...]) |
| mDNS discovery | enable_mdns() |
|
| Peer exchange | enable_peer_exchange() |
|
| JSON messages | enable_json() |
on_json, once_json, off_json, send_json, broadcast_json |
| Pub/sub (GossipSub) | enable_pubsub() |
subscribe, unsubscribe, publish |
| File transfer | enable_file_transfer(temp_dir=None) |
on_file_offer, on_file_progress, on_file_complete, send_file, send_directory, accept_file, reject_file, pause_file, resume_file, cancel_file |
| Port mapping | enable_port_mapping(upnp=True, natpmp=True) |
|
| Hole punching | enable_hole_punch(serve_rendezvous=True) |
punch_peer(peer_id), nat_mapping |
| Relaying | enable_relay(serve=False) |
connect_via_relay(peer_id) |
| Ping | enable_ping() |
peer_rtt_ms(peer_id) |
| Reconnection | enable_reconnect() or enable_reconnect_lost(lost_for_ms) |
add_reconnect(host, port), remove_reconnect(host, port) |
JSON messages and pub/sub
JSON messages are channels that carry values instead of bytes. send_json
encodes the value with json.dumps, and the handler receives it parsed. Values
come from other machines, so check their shape before trusting them.
Pub/sub works by topic instead of by peer. A published message reaches every node subscribed to the topic, including through peers in between, so it scales to groups where not everybody is connected to everybody.
node.enable_json()
node.on_json("greeting", lambda peer_id, value: print(value["hello"]))
node.enable_pubsub()
node.subscribe("news", lambda peer_id, topic, data: print(topic, data.decode()))
node.start()
node.broadcast_json("greeting", {"hello": "world"})
node.publish("news", b"something happened")
Each topic has one handler; subscribing again replaces it. on_json adds a
handler, once_json adds one that is removed after its first message, and
off_json removes them all.
File transfer
The sender offers a file or a directory. The receiver accepts it, choosing where
to save it, or rejects it. Both sides name a transfer by (peer_id, transfer_id).
import os
node.enable_file_transfer("./tmp") # where partial downloads are kept
def on_offer(peer_id, transfer_id, name, size, is_directory):
# `name` comes from the sender; basename() keeps a name like '../../x' in downloads/
node.accept_file(peer_id, transfer_id, os.path.join("./downloads", os.path.basename(name)))
node.on_file_offer(on_offer)
node.on_file_progress(lambda p: print(p.transfer_id, p.status.name, f"{p.bytes_transferred}/{p.total_bytes}"))
node.on_file_complete(lambda peer_id, transfer_id, ok, path: print(transfer_id, ok, path))
node.start()
transfer_id = node.send_file(peer_id, "./video.mp4") # RatsNoSuchPeerError / RatsNotFoundError
The name in an offer is whatever the sender put there, so never use it as a path unchecked. librats does check the paths inside an offered directory and rejects the offer if one of them leads outside it. The three file handlers hold one handler each; registering again replaces it.
NAT traversal
Most nodes sit behind a router that does not accept incoming connections, and two such nodes cannot simply dial each other. librats has three remedies, from the cheapest to the most expensive:
- Port mapping asks the router to forward the port, over UPnP or NAT-PMP. Many home routers agree; corporate and mobile networks usually do not.
- Hole punching: a peer both sides are already connected to arranges for
them to dial each other at the same moment, which opens both NATs. Every node
involved needs
enable_hole_punch(). It cannot work behind a symmetric NAT, whichnode.nat_mapping == NatMapping.ENDPOINT_DEPENDENTreports. - Relaying carries the connection through a peer both sides share. It stays
encrypted end to end, so the relay cannot read it, and
peer_info(peer_id).transportreportsTransport.RELAY. Only nodes started withenable_relay(serve=True)carry other peers' traffic, because it costs them bandwidth.
node.enable_port_mapping()
node.enable_hole_punch()
node.enable_relay()
node.start()
node.punch_peer(peer_id) # RatsDeclinedError if there is nothing to do or nobody to help
With hole punching and relaying both enabled, a punch that cannot work falls back to a relay by itself, and a relayed peer moves to a direct connection when a later punch succeeds.
Reconnection
enable_reconnect() redials peers that dropped, with growing pauses between
attempts. With data_dir the list survives a restart. add_reconnect(host, port)
adds an address to keep connected, and can be called while the node is running.
enable_reconnect_lost(lost_for_ms) does the same and also covers a peer that was
lost rather than one that left: a peer whose connection closed with
neither side closing it, because it went silent or stopped answering. Such a
peer is redialed where it was last seen for up to lost_for_ms. Use one of the
two, not both.
Configuration
Everything is optional; the port is the one positional argument:
RatsNode(
8080, # listen port; default 0, any free port
protocol="my-app/1.0", # default "librats/1.0"
data_dir="./state", # identity, DHT table, reconnect list; default none
max_peers=100, # default 0, no cap
)
| Keyword | Default | What it does |
|---|---|---|
listen_port |
0 |
shared by UDP and TCP; 0 picks a free one |
protocol |
"librats/1.0" |
app id bound into the handshake |
data_dir |
none | where the identity, DHT routing table and reconnect list are kept |
security |
Security.NOISE |
Security.PLAINTEXT turns encryption off, for debugging only |
bind_address |
"::" |
every interface, IPv4 and IPv6 |
dial_only |
False |
no listener, outgoing connections only |
max_peers |
0 |
cap on connected peers, enforced on incoming ones; 0 is no cap |
transports |
both | TransportMask.UDP, TransportMask.TCP, or both |
preferred_transport |
Transport.UDP |
which one a dial tries first |
transport_fallback_ms |
1200 | how long the first transport tries alone before the other joins; FALLBACK_NEVER is never |
send_queue_limit |
8 MiB | queue size past which a peer is dropped as SLOW_CONSUMER |
path_sample_ms |
0 |
how often path_stats is refreshed; 0 is only while an on_peer_path handler exists |
congestion_control |
Congestion.BBR |
or Congestion.RENO, for UDP connections |
udp_receive_window |
16 MiB | flow-control window of one UDP connection |
udp_receive_budget |
64 MiB | out-of-order bytes all UDP connections may hold together |
udp_offload |
True |
kernel segmentation offload for UDP, where available |
udp_idle_timeout_ms |
45000 | silence after which a UDP peer counts as lost (IDLE_TIMEOUT) |
reactor_threads |
1 |
networking threads |
network_monitor |
True |
react when interfaces or addresses change |
A combination no node can run with, such as preferred_transport=Transport.TCP
with transports=TransportMask.UDP, makes the constructor raise
RatsInvalidArgError.
Lifecycle
node = RatsNode(...) # nothing runs yet
node.start() # sockets open, features start
node.stop() # every connection closes; start() works again
node.destroy() # stopped and freed; any later call raises RatsError
with RatsNode(...) as node:destroys the node when the block ends, also on an exception.- A node nobody destroys is destroyed when it is garbage-collected or when the program exits.
- A node cannot be stopped or destroyed from inside its own handler: that
would wait for the very thread the handler runs on.
stop()anddestroy()raiseRuntimeErrorthere. Signal the main thread instead, as the hello world does with anEvent. - A restarted node keeps its id and features, but not its connections: dial again, or let discovery and reconnection do it.
Errors
A failed call raises a subclass of RatsError; its error_code names the cause:
from librats_py import RatsNoSuchPeerError
try:
node.send(peer_id, "chat", b"hi")
except RatsNoSuchPeerError:
pass # the peer left in the meantime
| Exception | Meaning |
|---|---|
RatsAlreadyStartedError |
this has to happen before start() |
RatsNotStartedError |
this has to happen after start() |
RatsNotEnabledError |
the feature was not enabled |
RatsNoSuchPeerError |
the peer is not connected |
RatsNotFoundError |
no such transfer, or a file to send that cannot be read |
RatsBindError |
the port is taken |
RatsInvalidArgError |
a bad argument, such as a malformed peer id, or a configuration that cannot work |
RatsDeclinedError |
a punch or relay attempt has nothing to do or nobody to help |
RatsError |
the base class; also what any call on a destroyed node raises |
Lookups for which "nothing" is a normal answer return None instead of raising:
peer_info, path_stats, peer_rtt_ms. Arguments are not converted for you:
ids, names and hosts are str, ports and sizes int, data bytes. Anything
else fails with a Python error such as TypeError before it reaches the
library.
Logging and version
from librats_py import LogLevel, set_log_level, set_log_file, version
set_log_level(LogLevel.WARN) # DEBUG | INFO (default) | WARN | ERROR
set_log_file("./librats.log") # also write to a file; set_log_file(None) stops it
print(version()) # e.g. '3.0.5.0'
Logs go to the console: errors to stderr, everything else to stdout. These
settings apply to the whole library, every node in the process.
version_info() gives the version as numbers and git_describe() the exact
build.
Examples
chat |
the chat above |
gossipsub_chat |
a chat over pub/sub |
basic_client |
channels and peer events, driven by typed commands |
file_transfer |
sending and receiving files |
They are installed with the package and run as modules:
python -m librats_py.examples.chat alice
python -m librats_py.examples.gossipsub_chat 8080 alice general
python -m librats_py.examples.basic_client 8080
python -m librats_py.examples.file_transfer 8080 ./downloads
Building from source
For a platform without a ready package, or to work on the bindings. You need CMake 3.14 or newer and a C++17 compiler.
git clone https://github.com/librats/librats.git
cd librats/python
python build.py --build-native # builds the shared library, copies it next to the package
pip install -e .
The library is looked for in the directories in LIBRATS_LIBRARY_PATH, then
next to the package, then in build/lib, build/bin and build/ of the
repository, and finally by the system loader. The file is rats.dll on
Windows, librats.dylib on macOS, librats.so elsewhere. A MinGW build of
the DLL also needs libstdc++-6.dll, libgcc_s_seh-1.dll and
libwinpthread-1.dll beside it: Python does not search PATH for a DLL's
dependencies.
Tests: pip install pytest, then python -m pytest librats_py/tests. Tests
that need the library are skipped when it cannot be loaded.
PYTHON_BINDINGS.md
explains how the package is put together.
License
MIT, see LICENSE.
Metadata
Release files for librats 3.0.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Actions Reset | |||
|---|---|---|---|---|
| librats-3.0.6-py3-none-win_amd64.whl | Python 3 | none | Windows x86-64 | Details for librats-3.0.6-py3-none-win_amd64.whl |
| librats-3.0.6-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl | Python 3 | none | Linux glibc 2.27+ x86-64, Linux glibc 2.28+ x86-64 | Details for librats-3.0.6-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl |
| librats-3.0.6-py3-none-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl | Python 3 | none | Linux glibc 2.28+ ARM64, Linux glibc 2.26+ ARM64 | Details for librats-3.0.6-py3-none-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl |
| librats-3.0.6-py3-none-macosx_11_0_universal2.whl | Python 3 | none | macOS 11.0+ universal2 (ARM64, x86-64) | Details for librats-3.0.6-py3-none-macosx_11_0_universal2.whl |
Total release size: 5.7 MB
Release files / librats-3.0.6-py3-none-win_amd64.whl
| Download URL | librats-3.0.6-py3-none-win_amd64.whl |
|---|---|
| Size | 1.1 MB |
| Tags | Python 3 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
a3ceec753ca67735238c7df4c406baa7b25013b9ad33cb4fc2690ce1617c6166
|
|
BLAKE2b-256 checksum How to use checksums |
ad3a520a45476856eeabab1cb48a7449962442a71e8e22dda83a0fcce4125d61
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logRelease files / librats-3.0.6-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
| Download URL | librats-3.0.6-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl |
|---|---|
| Size | 1.5 MB |
| Tags | Linux glibc 2.27+ x86-64 Linux glibc 2.28+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
242705c9867b59a3429cbeec44ff730cbffb01a1233c3c4dbbd0ffb17c7379d9
|
|
BLAKE2b-256 checksum How to use checksums |
60cb01cf94cba0b6f7b2830bda0f208e25c3b740883c560838a6a795c97c9f25
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logRelease files / librats-3.0.6-py3-none-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
| Download URL | librats-3.0.6-py3-none-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl |
|---|---|
| Size | 1.3 MB |
| Tags | Linux glibc 2.26+ ARM64 Linux glibc 2.28+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
e65dc1293aeb72c9871dc5479e7ac22d38f6062f5e1522ae89ef2fe02063fc1d
|
|
BLAKE2b-256 checksum How to use checksums |
581f67a2d572d13fc9127cd885a1d7b837d79727d5644e8da01afaedb8a224d6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logRelease files / librats-3.0.6-py3-none-macosx_11_0_universal2.whl
| Download URL | librats-3.0.6-py3-none-macosx_11_0_universal2.whl |
|---|---|
| Size | 1.8 MB |
| Tags | Python 3 macOS 11.0+ universal2 (ARM64, x86-64) |
|
SHA-256 checksum How to use checksums |
99fe5fbe390603147cb428ede58cd722ce22e173d5273f7f149cbcade4fee48c
|
|
BLAKE2b-256 checksum How to use checksums |
4f76d3b0aaa7db2e3a351cf22b04f771fca754524936ce14f07f7dbdc73ddab4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logSecurity for librats 3.0.6
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 4 files in this release have been uploaded with provenance metadata.
Attestation for librats-3.0.6-py3-none-win_amd64.whl
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.