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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pynet = pynet.cli:main
@@ -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