fromscratch-pynet 0.2.0__py3-none-any.whl
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.
- fromscratch_pynet-0.2.0.dist-info/METADATA +250 -0
- fromscratch_pynet-0.2.0.dist-info/RECORD +44 -0
- fromscratch_pynet-0.2.0.dist-info/WHEEL +4 -0
- fromscratch_pynet-0.2.0.dist-info/entry_points.txt +2 -0
- fromscratch_pynet-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynet/__init__.py +1 -0
- pynet/_digits.py +16 -0
- pynet/_version.py +24 -0
- pynet/base64.py +138 -0
- pynet/cli.py +28 -0
- pynet/dns.py +79 -0
- pynet/errors.py +88 -0
- pynet/framing.py +171 -0
- pynet/ftp.py +54 -0
- pynet/http.py +271 -0
- pynet/json_client.py +82 -0
- pynet/loop/__init__.py +46 -0
- pynet/loop/children.py +351 -0
- pynet/loop/control.py +237 -0
- pynet/loop/futures.py +187 -0
- pynet/loop/reactor.py +502 -0
- pynet/loop/sync.py +295 -0
- pynet/loop/tasks.py +296 -0
- pynet/loop/testing.py +37 -0
- pynet/loop/threads.py +164 -0
- pynet/messaging/__init__.py +92 -0
- pynet/messaging/codec.py +244 -0
- pynet/messaging/peer.py +659 -0
- pynet/messaging/reconnect.py +338 -0
- pynet/ping.py +64 -0
- pynet/process.py +236 -0
- pynet/py.typed +0 -0
- pynet/smtp.py +76 -0
- pynet/streams.py +861 -0
- pynet/tcp.py +129 -0
- pynet/tls.py +81 -0
- pynet/udp.py +53 -0
- pynet/url.py +117 -0
- pynet/websocket.py +90 -0
- pynet/ws/__init__.py +81 -0
- pynet/ws/connection.py +393 -0
- pynet/ws/endpoints.py +200 -0
- pynet/ws/frames.py +257 -0
- pynet/ws/handshake.py +206 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pynet
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An educational networking library built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pynet
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pynet
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pynet/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pynet/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: dns,education,http-client,networking,tcp
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Internet
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-timeout>=2.3.1; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# PyNet
|
|
35
|
+
|
|
36
|
+
**An educational networking library built from scratch in Python.**
|
|
37
|
+
|
|
38
|
+
## Telephones for Computers
|
|
39
|
+
|
|
40
|
+
Imagine you want to talk to your friend who lives across town. You pick up the
|
|
41
|
+
phone, dial their number, and start chatting. The internet works the same way!
|
|
42
|
+
|
|
43
|
+
When your computer visits a website, it:
|
|
44
|
+
|
|
45
|
+
1. **Reads the address** -- like reading a phone number off a sticky note
|
|
46
|
+
(URL parsing)
|
|
47
|
+
2. **Looks up the number** -- like finding someone in a phone book
|
|
48
|
+
(DNS lookup)
|
|
49
|
+
3. **Dials and talks** -- like making the phone call and having a conversation
|
|
50
|
+
(HTTP request and response)
|
|
51
|
+
|
|
52
|
+
PyNet teaches you how all of this works by building each piece from scratch.
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
### URL Parsing -- Reading the Address
|
|
57
|
+
|
|
58
|
+
A URL is like a phone number with extra info: who to call, what department,
|
|
59
|
+
and what to ask about.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pynet.url import parse_url
|
|
63
|
+
|
|
64
|
+
url = parse_url("https://example.com:8080/api/users?page=2#results")
|
|
65
|
+
|
|
66
|
+
print(url.scheme) # "https" -- how to connect
|
|
67
|
+
print(url.host) # "example.com" -- who to call
|
|
68
|
+
print(url.port) # 8080 -- which door to knock on
|
|
69
|
+
print(url.path) # "/api/users" -- what to ask for
|
|
70
|
+
print(url.query) # {"page": "2"} -- extra details
|
|
71
|
+
print(url.fragment) # "results" -- where to scroll on the page
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### DNS Lookup -- The Phone Book
|
|
75
|
+
|
|
76
|
+
DNS translates names like `example.com` into numbers like `93.184.216.34`
|
|
77
|
+
that computers actually use to find each other.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pynet.dns import resolve, is_valid_ipv4
|
|
81
|
+
|
|
82
|
+
ip = resolve("localhost")
|
|
83
|
+
print(ip) # "127.0.0.1" -- your own computer!
|
|
84
|
+
|
|
85
|
+
print(is_valid_ipv4("192.168.1.1")) # True
|
|
86
|
+
print(is_valid_ipv4("999.0.0.1")) # False
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### HTTP GET -- Making the Call
|
|
90
|
+
|
|
91
|
+
An HTTP request is like picking up the phone and asking a question. The server
|
|
92
|
+
picks up and gives you an answer.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pynet.http import get
|
|
96
|
+
|
|
97
|
+
response = get("http://example.com/")
|
|
98
|
+
print(response.status) # 200 (OK -- the server understood!)
|
|
99
|
+
print(response.body[:50]) # The first 50 characters of the page
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### JSON API -- Structured Conversations
|
|
103
|
+
|
|
104
|
+
When programs talk to each other, they use JSON -- a structured format both
|
|
105
|
+
sides understand, like filling out a form instead of writing a free-form letter.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from pynet.json_client import json_get
|
|
109
|
+
|
|
110
|
+
data = json_get("http://api.example.com/users/1")
|
|
111
|
+
print(data["name"]) # The user's name from the API
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Real Networking -- A Phone Line That Rings
|
|
115
|
+
|
|
116
|
+
PyNet has its own event loop, built from scratch, and real sockets that run
|
|
117
|
+
on it. This echo server and client talk over a real TCP connection on your
|
|
118
|
+
own computer:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from pynet.loop import run
|
|
122
|
+
from pynet.streams import open_connection, start_server
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def echo(reader, writer):
|
|
126
|
+
data = await reader.read(1024)
|
|
127
|
+
writer.write(data)
|
|
128
|
+
await writer.drain()
|
|
129
|
+
writer.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def main():
|
|
133
|
+
async with await start_server(echo, "127.0.0.1", 0) as server:
|
|
134
|
+
reader, writer = await open_connection("127.0.0.1", server.port)
|
|
135
|
+
writer.write(b"hello, phone line!")
|
|
136
|
+
await writer.drain()
|
|
137
|
+
print(await reader.read(1024))
|
|
138
|
+
writer.close()
|
|
139
|
+
await writer.wait_closed()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
run(main()) # b'hello, phone line!'
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Features
|
|
146
|
+
|
|
147
|
+
- **URL Parsing** -- Break apart web addresses into their components
|
|
148
|
+
- **DNS Resolution** -- Translate hostnames into IP addresses
|
|
149
|
+
- **HTTP Client** -- Send GET and POST requests over raw TCP sockets
|
|
150
|
+
- **JSON Client** -- Talk to web APIs with structured data
|
|
151
|
+
- **Custom Errors** -- Clear error types for DNS, connection, and HTTP failures
|
|
152
|
+
- **TCP** -- Reliable, ordered byte-stream delivery
|
|
153
|
+
- **UDP** -- Fast, connectionless datagrams
|
|
154
|
+
- **WebSocket** -- Full-duplex communication over a single connection
|
|
155
|
+
- **SMTP** -- Send emails programmatically
|
|
156
|
+
- **FTP** -- Transfer files between machines
|
|
157
|
+
- **Ping/ICMP** -- Check if a host is reachable
|
|
158
|
+
- **Base64** -- Encode and decode binary data as text
|
|
159
|
+
- **TLS** -- Encrypted connections with certificates
|
|
160
|
+
- **Event Loop** -- A from-scratch `select`-based loop with timers, tasks,
|
|
161
|
+
`await`, cancellation, timeouts, and sync primitives (Event, Queue, Lock,
|
|
162
|
+
Semaphore)
|
|
163
|
+
- **Real TCP Streams** -- Non-blocking `start_server` and `open_connection`
|
|
164
|
+
with backpressure
|
|
165
|
+
- **Subprocesses** -- Run many commands at once and kill whole process groups
|
|
166
|
+
- **Framing and Messaging** -- Length-prefixed frames and request/reply/push
|
|
167
|
+
messages with binary attachments
|
|
168
|
+
- **Heartbeats and Reconnect** -- Ping/pong liveness checks and redialling
|
|
169
|
+
with exponential backoff and jitter
|
|
170
|
+
- **Real WebSocket** -- RFC 6455 handshake, frames, and live connections
|
|
171
|
+
- **CLI Tool** -- Interactive demo of networking concepts
|
|
172
|
+
- **100% Typed** -- Full type annotations with strict Pyright checking
|
|
173
|
+
|
|
174
|
+
## Quick Start
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Install with uv
|
|
178
|
+
uv add pynet
|
|
179
|
+
|
|
180
|
+
# Or install from source
|
|
181
|
+
git clone https://github.com/robcox2910/pynet.git
|
|
182
|
+
cd pynet
|
|
183
|
+
uv sync --all-extras
|
|
184
|
+
|
|
185
|
+
# Run the CLI demo
|
|
186
|
+
uv run pynet
|
|
187
|
+
|
|
188
|
+
# Run the tests
|
|
189
|
+
uv run pytest
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Examples to Run
|
|
193
|
+
|
|
194
|
+
Each file in `examples/` runs with `uv run python examples/<name>.py`:
|
|
195
|
+
|
|
196
|
+
- `echo_server.py` -- a parrot on a real TCP line (try it with `nc 127.0.0.1 8888`)
|
|
197
|
+
- `job_runner.py` -- start three commands at once and watch each finish
|
|
198
|
+
- `chat_room.py` -- a group channel built on messaging requests and pushes
|
|
199
|
+
- `heartbeat_demo.py` -- watch pings go out and a frozen server get noticed
|
|
200
|
+
- `ws_echo.py` -- a WebSocket echo server you can connect to from a browser
|
|
201
|
+
|
|
202
|
+
<!-- docs:start -->
|
|
203
|
+
## Documentation
|
|
204
|
+
|
|
205
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pynet/
|
|
206
|
+
<!-- docs:end -->
|
|
207
|
+
|
|
208
|
+
## Releasing
|
|
209
|
+
|
|
210
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
211
|
+
not whatever is on `main`. To publish a change for them:
|
|
212
|
+
|
|
213
|
+
1. Merge the change to `main`.
|
|
214
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
215
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
216
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
217
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
218
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
219
|
+
the pair together.
|
|
220
|
+
|
|
221
|
+
<!-- series:start -->
|
|
222
|
+
## Related Projects
|
|
223
|
+
|
|
224
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
225
|
+
|
|
226
|
+
| Project | What it teaches | Repository | Docs |
|
|
227
|
+
| --- | --- | --- | --- |
|
|
228
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
229
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
230
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
231
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
232
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
233
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
234
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
235
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
236
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
237
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
238
|
+
| **PyNet** | **Computer networking** | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
239
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
240
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
241
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
242
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
243
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
244
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
245
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
246
|
+
<!-- series:end -->
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT -- see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
pynet/__init__.py,sha256=Gd93b9FAwwz1xa99NXD2Z8gw9jUsVvqlN6B28ORlUAM,68
|
|
2
|
+
pynet/_digits.py,sha256=KuVOxpFlN5t7-g1KjYgvmL4aMqQweX8p27gBeTrbJL0,782
|
|
3
|
+
pynet/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
|
|
4
|
+
pynet/base64.py,sha256=pDejyhmQPlvy7RQsPF_QEG6X-piSCm5iApM8di8wLmA,4558
|
|
5
|
+
pynet/cli.py,sha256=3ITGTu0rxl4TvfbmFYHpyqlJ6_L70o9z9Npuv4rfKb4,981
|
|
6
|
+
pynet/dns.py,sha256=0e8wO1unnBOY1n54WeZ3egUvuJHwDlAy_cvDLjTgmRM,2360
|
|
7
|
+
pynet/errors.py,sha256=QWQ7Aq3jYpn0QVdsx9l3L0SZ9pmXLBiwzArKu9lFayU,2629
|
|
8
|
+
pynet/framing.py,sha256=307nrzW_6-uKGWj29BJO3oXlqt2dp1FSiatRI4H77ro,6373
|
|
9
|
+
pynet/ftp.py,sha256=GtVxxFY5nVjH4lv9_pmjoWR1rF6d073wDFhwz_FxkRM,1454
|
|
10
|
+
pynet/http.py,sha256=DWXKllJgaHrdu8LtoDZIzhPAfwLkZLWaSnsiEMad_Lg,8928
|
|
11
|
+
pynet/json_client.py,sha256=RRMjULPi7ejDpiEKk9wmZ9BGjqoxVAvL0jh1k6jIT3s,2140
|
|
12
|
+
pynet/ping.py,sha256=EcxeWuyftKA081ENMH_F7u_FuIZPvIdgRTUDVseVF4Y,1880
|
|
13
|
+
pynet/process.py,sha256=913oPCNVRqIGA0dO_Ry1RtQG4bBpvJMW1-c-J05jvXE,9465
|
|
14
|
+
pynet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
pynet/smtp.py,sha256=LOqu7jULRZ841fUFvGjg8vpIu2Sea8q9cT-xwBCgJcA,2012
|
|
16
|
+
pynet/streams.py,sha256=OtXdefLcbZQuCz7gzYsR8whC6XDC258Oj0qOMTTqWVM,31718
|
|
17
|
+
pynet/tcp.py,sha256=6ZWJQKQNmqqVNo0pH2Tl3TOmN9ZtqXjP_pPAs5E_rhM,3632
|
|
18
|
+
pynet/tls.py,sha256=h31JqId6qbk_I3JxlfuzM90TBb2nrcnhFbjYNrFDYEE,2338
|
|
19
|
+
pynet/udp.py,sha256=Qy8vLtQfyv4QJdPCLdM5xuPcMqZSv_Vhb-zV-OR552E,1413
|
|
20
|
+
pynet/url.py,sha256=D4uECCPLZk9ifIzMT5n2pPFxpJe7Ssu3NjLPMu9u2iw,3021
|
|
21
|
+
pynet/websocket.py,sha256=BTDkZPR533u2KcvjjlG8T9xY0nen9OJQI938pfJogMg,2514
|
|
22
|
+
pynet/loop/__init__.py,sha256=FJJyYsczJCg9NhZW02MfWkkWQWvg5SZE9LykRqLql-I,1331
|
|
23
|
+
pynet/loop/children.py,sha256=2PLXxRgVG1guS0_Rtjlw6N5zfetzFKP1NCW4EQSKIyI,14408
|
|
24
|
+
pynet/loop/control.py,sha256=w5_UWcDGZGFnc3acoUVzPd4VqSAIh4oTJOrZIUGBpWk,8407
|
|
25
|
+
pynet/loop/futures.py,sha256=uPkPw1rzNkyrUPYFiH5oxHMLPzGsoOChPt3AQO8k1SM,6517
|
|
26
|
+
pynet/loop/reactor.py,sha256=L22dN8yCtihOLGaFwfWwQIzxUZ_mz5HmAwn48xMCTHM,19412
|
|
27
|
+
pynet/loop/sync.py,sha256=bkSsYytLEP2Z_7CVb3_hpmBO1L2iq81_QgdUXEcDTCU,9388
|
|
28
|
+
pynet/loop/tasks.py,sha256=5FpCvSgUEwGC1yloEmr78pXp1IkJHFoOGSOTonTkTqY,10483
|
|
29
|
+
pynet/loop/testing.py,sha256=Dkq2wIV7tfduo-WWKD_JKal_ubXEhguAAEw0DMrZvuU,1226
|
|
30
|
+
pynet/loop/threads.py,sha256=ohRhnGJdxYrtJvgcKEP8C-pSYTuiMlgaY0So0ldIzeM,5968
|
|
31
|
+
pynet/messaging/__init__.py,sha256=hm1EvnkxwQyOQg0ydpav3AjLOiFj-rXA_8L94m0n0T8,2113
|
|
32
|
+
pynet/messaging/codec.py,sha256=ITZlVcscBkLaWPK9iC6BZSl4d4qTd1dTbLQGulURvZ0,9110
|
|
33
|
+
pynet/messaging/peer.py,sha256=i7brBlBMMNJ_uIznNjFmgH35_PM8Lc-Uj3KNJJp4FXQ,26290
|
|
34
|
+
pynet/messaging/reconnect.py,sha256=pneC4IRujYR_OrJghQNidjHGpPK65S6HOnKiO5zdyDA,12714
|
|
35
|
+
pynet/ws/__init__.py,sha256=3ajwTCDIhnol457NnAuaybVVk20uTwW5YVCSfxiUO9g,2040
|
|
36
|
+
pynet/ws/connection.py,sha256=eP-EPCOTbzMlPLxp89Hk7oxKJDQpvKElaqx2eBZ1B-I,15199
|
|
37
|
+
pynet/ws/endpoints.py,sha256=_byHj50stgRroiw55HvKyOqchFh7nh9F5V1e4RIQGwc,6369
|
|
38
|
+
pynet/ws/frames.py,sha256=mlfvI7i4kUOKE9Mb0G1w1FlpRAwJyOyF2CQWCEFYVKo,9517
|
|
39
|
+
pynet/ws/handshake.py,sha256=QV64N7NsMauhoFkYMX1kU-0gnJstibuMqWtTURaNFCI,6596
|
|
40
|
+
fromscratch_pynet-0.2.0.dist-info/METADATA,sha256=MLISQCxo75Or8pHKLLlZpKB_mYQxBM7g_LVK6FX0sxo,10224
|
|
41
|
+
fromscratch_pynet-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
42
|
+
fromscratch_pynet-0.2.0.dist-info/entry_points.txt,sha256=JqXhtu2X2GLwhcerLL2MPPK9VK4EfqotD-snbQ-9TZA,41
|
|
43
|
+
fromscratch_pynet-0.2.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
44
|
+
fromscratch_pynet-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pynet/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PyNet -- educational networking built from scratch in Python."""
|
pynet/_digits.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Plain digits -- the one place that says what counts as a digit.
|
|
2
|
+
|
|
3
|
+
Python's ``str.isdigit`` and ``int()`` are kinder than internet protocols:
|
|
4
|
+
they accept ``"²"``, Arabic-Indic digits like ``"٣"``, and ``int()`` also
|
|
5
|
+
reads ``"-5"``, ``"+3"``, ``"1_0"`` and ``" 7 "``. Ports, status codes,
|
|
6
|
+
sizes and IPv4 pieces are only ever the ASCII digits, so the HTTP parser,
|
|
7
|
+
the URL parser and the DNS check all ask this module instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
DECIMAL_DIGITS = frozenset("0123456789")
|
|
11
|
+
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_plain_digits(text: str, *, hexadecimal: bool = False) -> bool:
|
|
15
|
+
"""Say whether ``text`` is one or more plain digits (0-9, or 0-9 and a-f if ``hexadecimal``)."""
|
|
16
|
+
return bool(text) and set(text) <= (HEX_DIGITS if hexadecimal else DECIMAL_DIGITS)
|
pynet/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pynet/base64.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Encoding photos for email -- binary to text.
|
|
2
|
+
|
|
3
|
+
Implement Base64 encoding and decoding from scratch. Base64 converts
|
|
4
|
+
arbitrary binary data into a safe ASCII string using 64 printable
|
|
5
|
+
characters. Think of it as translating a photograph into a sequence
|
|
6
|
+
of letters so you can paste it into an email.
|
|
7
|
+
|
|
8
|
+
The algorithm groups every 3 bytes (24 bits) into four 6-bit chunks,
|
|
9
|
+
then maps each chunk to a character from the Base64 alphabet. If the
|
|
10
|
+
input length is not a multiple of 3, the output is padded with '='.
|
|
11
|
+
|
|
12
|
+
``b64_encode`` accepts raw ``bytes`` (like the contents of a photo)
|
|
13
|
+
*or* a text string for convenience. ``b64_decode`` in this from-scratch
|
|
14
|
+
version turns Base64 back into a text string, while ``b64_decode_bytes``
|
|
15
|
+
recovers the raw bytes -- the photo comes back exactly as it left.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
19
|
+
_PAD = "="
|
|
20
|
+
|
|
21
|
+
BITS_PER_BYTE = 8
|
|
22
|
+
BITS_PER_SEXTET = 6
|
|
23
|
+
SEXTET_MASK = 0x3F
|
|
24
|
+
INPUT_GROUP_SIZE = 3
|
|
25
|
+
OUTPUT_GROUP_SIZE = 4
|
|
26
|
+
MAX_PADDING = 2
|
|
27
|
+
BYTE_MASK = 0xFF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def b64_encode(data: bytes | str) -> str:
|
|
31
|
+
"""Encode raw bytes (or text) to Base64.
|
|
32
|
+
|
|
33
|
+
Convert each group of 3 bytes into 4 Base64 characters, padding
|
|
34
|
+
with '=' when the input length is not a multiple of 3. Pass real
|
|
35
|
+
binary like a photo's bytes, or a string (encoded as UTF-8 for you).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
data: The bytes to encode, or a text string.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The Base64-encoded string.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
raw = data.encode("utf-8") if isinstance(data, str) else data
|
|
45
|
+
result: list[str] = []
|
|
46
|
+
|
|
47
|
+
for i in range(0, len(raw), INPUT_GROUP_SIZE):
|
|
48
|
+
group = raw[i : i + INPUT_GROUP_SIZE]
|
|
49
|
+
# Combine bytes into a single integer.
|
|
50
|
+
combined = 0
|
|
51
|
+
for byte in group:
|
|
52
|
+
combined = (combined << BITS_PER_BYTE) | byte
|
|
53
|
+
|
|
54
|
+
# Shift left to fill a full 24-bit block if group is short.
|
|
55
|
+
padding_bytes = INPUT_GROUP_SIZE - len(group)
|
|
56
|
+
combined <<= padding_bytes * BITS_PER_BYTE
|
|
57
|
+
|
|
58
|
+
# Extract four 6-bit sextets from the 24-bit block.
|
|
59
|
+
sextets: list[int] = []
|
|
60
|
+
for _ in range(OUTPUT_GROUP_SIZE):
|
|
61
|
+
sextets.append(combined & SEXTET_MASK)
|
|
62
|
+
combined >>= BITS_PER_SEXTET
|
|
63
|
+
sextets.reverse()
|
|
64
|
+
|
|
65
|
+
# Map sextets to characters, replacing trailing ones with padding.
|
|
66
|
+
chars_to_write = OUTPUT_GROUP_SIZE - padding_bytes
|
|
67
|
+
for j, sextet in enumerate(sextets):
|
|
68
|
+
if j < chars_to_write:
|
|
69
|
+
result.append(_ALPHABET[sextet])
|
|
70
|
+
else:
|
|
71
|
+
result.append(_PAD)
|
|
72
|
+
|
|
73
|
+
return "".join(result)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def b64_decode_bytes(encoded: str) -> bytes:
|
|
77
|
+
"""Decode a Base64 string back to the raw bytes it came from.
|
|
78
|
+
|
|
79
|
+
Unlike ``b64_decode`` the result is never forced through UTF-8, so
|
|
80
|
+
binary data (a photo, a random key) survives the round trip. The
|
|
81
|
+
input must be strictly valid: a length that is a multiple of 4,
|
|
82
|
+
alphabet characters only, and at most two ``=`` signs, at the end.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
encoded: The Base64-encoded string.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The decoded bytes.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If the input has invalid characters, length or padding.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
if not encoded:
|
|
95
|
+
return b""
|
|
96
|
+
if len(encoded) % OUTPUT_GROUP_SIZE:
|
|
97
|
+
msg = f"Invalid Base64 length: {len(encoded)} is not a multiple of 4"
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
|
|
100
|
+
body = encoded.rstrip(_PAD)
|
|
101
|
+
pad_count = len(encoded) - len(body)
|
|
102
|
+
if pad_count > MAX_PADDING:
|
|
103
|
+
msg = f"Invalid Base64 padding: {pad_count} '=' signs"
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
|
|
106
|
+
decode_map: dict[str, int] = {char: idx for idx, char in enumerate(_ALPHABET)}
|
|
107
|
+
result = bytearray()
|
|
108
|
+
bit_buffer = 0
|
|
109
|
+
bit_count = 0
|
|
110
|
+
for char in body:
|
|
111
|
+
if char not in decode_map:
|
|
112
|
+
msg = f"Invalid Base64 character: {char!r}"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
bit_buffer = (bit_buffer << BITS_PER_SEXTET) | decode_map[char]
|
|
115
|
+
bit_count += BITS_PER_SEXTET
|
|
116
|
+
if bit_count >= BITS_PER_BYTE:
|
|
117
|
+
bit_count -= BITS_PER_BYTE
|
|
118
|
+
result.append((bit_buffer >> bit_count) & BYTE_MASK)
|
|
119
|
+
bit_buffer &= (1 << bit_count) - 1
|
|
120
|
+
return bytes(result)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def b64_decode(encoded: str) -> str:
|
|
124
|
+
"""Decode a Base64 string back to plain text.
|
|
125
|
+
|
|
126
|
+
Decode with ``b64_decode_bytes`` and read the bytes as UTF-8.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
encoded: The Base64-encoded string.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The decoded plain-text string.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If the input is not valid Base64 or not valid UTF-8.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
return b64_decode_bytes(encoded).decode("utf-8")
|
pynet/cli.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""PyNet CLI -- demonstrate networking concepts."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pynet.dns import resolve
|
|
6
|
+
from pynet.url import parse_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Run the PyNet demo showing URL parsing and DNS resolution."""
|
|
11
|
+
sys.stdout.write("PyNet -- Networking from scratch\n\n")
|
|
12
|
+
|
|
13
|
+
# Demo URL parsing.
|
|
14
|
+
demo_url = "https://example.com:8080/api/users?page=2#results"
|
|
15
|
+
sys.stdout.write(f"Parsing URL: {demo_url}\n")
|
|
16
|
+
parsed = parse_url(demo_url)
|
|
17
|
+
sys.stdout.write(f" Scheme: {parsed.scheme}\n")
|
|
18
|
+
sys.stdout.write(f" Host: {parsed.host}\n")
|
|
19
|
+
sys.stdout.write(f" Port: {parsed.port}\n")
|
|
20
|
+
sys.stdout.write(f" Path: {parsed.path}\n")
|
|
21
|
+
sys.stdout.write(f" Query: {parsed.query}\n")
|
|
22
|
+
sys.stdout.write(f" Fragment: {parsed.fragment}\n\n")
|
|
23
|
+
|
|
24
|
+
# Demo DNS resolution.
|
|
25
|
+
hostname = "localhost"
|
|
26
|
+
sys.stdout.write(f"Resolving {hostname!r}...\n")
|
|
27
|
+
ip_address = resolve(hostname)
|
|
28
|
+
sys.stdout.write(f" {hostname} -> {ip_address}\n")
|
pynet/dns.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""DNS resolution -- the phone book for the internet.
|
|
2
|
+
|
|
3
|
+
DNS translates human-friendly hostnames like `example.com` into
|
|
4
|
+
computer-friendly IP addresses like `93.184.216.34`. Without DNS,
|
|
5
|
+
you'd have to remember numbers for every website.
|
|
6
|
+
|
|
7
|
+
This module wraps Python's `socket.getaddrinfo` in a simple
|
|
8
|
+
educational interface.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import socket
|
|
12
|
+
|
|
13
|
+
from pynet._digits import is_plain_digits
|
|
14
|
+
from pynet.errors import DNSError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve(hostname: str) -> str:
|
|
18
|
+
"""Look up the IP address for a hostname.
|
|
19
|
+
|
|
20
|
+
Like looking up a name in a phone book to find the number.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
hostname: The hostname to resolve (e.g., "example.com").
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The IPv4 address as a string (e.g., "93.184.216.34").
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
DNSError: If the hostname cannot be resolved.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
results = socket.getaddrinfo(hostname, None, socket.AF_INET)
|
|
34
|
+
except socket.gaierror as exc:
|
|
35
|
+
msg = f"Could not resolve hostname: {hostname!r}"
|
|
36
|
+
raise DNSError(msg) from exc
|
|
37
|
+
|
|
38
|
+
if not results:
|
|
39
|
+
msg = f"No results for hostname: {hostname!r}"
|
|
40
|
+
raise DNSError(msg)
|
|
41
|
+
|
|
42
|
+
# Return the first IPv4 address found.
|
|
43
|
+
return str(results[0][4][0])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_valid_ipv4(address: str) -> bool:
|
|
47
|
+
"""Check whether a string is a valid IPv4 address.
|
|
48
|
+
|
|
49
|
+
Like checking a house number is written properly: it needs four
|
|
50
|
+
numbers split by dots, each from 0 to 255, and no sneaky leading
|
|
51
|
+
zeros ("192.168.001.1" is not a real address).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
address: The string to check.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if the string looks like a valid IPv4 address.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
parts = address.split(".")
|
|
61
|
+
expected_parts = 4
|
|
62
|
+
if len(parts) != expected_parts:
|
|
63
|
+
return False
|
|
64
|
+
return all(_is_valid_octet(p) for p in parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_valid_octet(part: str) -> bool:
|
|
68
|
+
"""Check whether one dotted piece of an IPv4 address is valid."""
|
|
69
|
+
max_octet = 255
|
|
70
|
+
max_digits = 3
|
|
71
|
+
# Reject anything but the ten digits 0-9 (Python's isdigit also says
|
|
72
|
+
# yes to "²" and to digits from other alphabets, like "٣"), leading
|
|
73
|
+
# zeros like "01" or "001", and more than 3 digits (thousands of them
|
|
74
|
+
# would even make int() itself refuse).
|
|
75
|
+
if not is_plain_digits(part) or (len(part) > 1 and part[0] == "0"):
|
|
76
|
+
return False
|
|
77
|
+
if len(part) > max_digits:
|
|
78
|
+
return False
|
|
79
|
+
return 0 <= int(part) <= max_octet
|