auldchat 2026.3.22.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.
auldchat/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Alexander Lloyd Lemna
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = importlib.metadata.version(__name__)
|
|
8
|
+
except importlib.metadata.PackageNotFoundError:
|
|
9
|
+
__version__ = "UNKNOWN" # Fallback for development mode
|
auldchat/__main__.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Alexander Lloyd Lemna
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
#
|
|
4
|
+
# Important note: This file is intended as an example for kids of cool
|
|
5
|
+
# things you can do with Python. I have added comments and explanations
|
|
6
|
+
# wherever possible, and aimed for a 4th-grade reading level.
|
|
7
|
+
#
|
|
8
|
+
"""
|
|
9
|
+
auldchat — A simple two-way command-line chat program.
|
|
10
|
+
======================================================
|
|
11
|
+
|
|
12
|
+
This program lets two people chat with each other over a local network
|
|
13
|
+
(like your home Wi-Fi) directly from the terminal. One person runs it
|
|
14
|
+
as a SERVER and waits for a connection. The other person runs it as a
|
|
15
|
+
CLIENT and connects to the server.
|
|
16
|
+
|
|
17
|
+
HOW TO USE IT
|
|
18
|
+
-------------
|
|
19
|
+
On the server computer, run:
|
|
20
|
+
python chat.py server --username Dad
|
|
21
|
+
|
|
22
|
+
On the client computer, run:
|
|
23
|
+
python chat.py client --host 192.168.1.42 --username Kid
|
|
24
|
+
|
|
25
|
+
(Replace 192.168.1.42 with the IP address the server printed out.)
|
|
26
|
+
|
|
27
|
+
SLASH COMMANDS (type these during chat)
|
|
28
|
+
----------------------------------------
|
|
29
|
+
/help — Show a list of commands
|
|
30
|
+
/status — Show connection info
|
|
31
|
+
/clear — Clear the screen
|
|
32
|
+
/quit — Disconnect and exit
|
|
33
|
+
|
|
34
|
+
HOW IT WORKS (the big picture)
|
|
35
|
+
--------------------------------
|
|
36
|
+
Messages are sent back and forth as JSON, which is just a tidy way of
|
|
37
|
+
packaging up text so computers can read it easily. Each message is on
|
|
38
|
+
its own line, which makes it simple to know where one message ends and
|
|
39
|
+
the next begins.
|
|
40
|
+
|
|
41
|
+
The program uses Python's "threading" module to do two things at once:
|
|
42
|
+
1. Listen for incoming messages from the other person.
|
|
43
|
+
2. Wait for you to type something and send it.
|
|
44
|
+
|
|
45
|
+
Without threads, the program would get stuck waiting for one thing and
|
|
46
|
+
could never do the other at the same time."""
|
|
47
|
+
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
# IMPORTS — grabbing some tools from the standard library
|
|
50
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
#
|
|
52
|
+
# Python comes with a giant built-in toolbox called the standard library.
|
|
53
|
+
# It gives us ready-made code for common jobs (like files, math, and
|
|
54
|
+
# networking), so we don't have to build every tool from scratch.
|
|
55
|
+
# An import statement tells Python which toolbox we want to use.
|
|
56
|
+
# Example: import socket means, "please let me use network tools."
|
|
57
|
+
#
|
|
58
|
+
|
|
59
|
+
import argparse # Reads the command-line arguments (like --host, --username)
|
|
60
|
+
import ipaddress # Checks if text looks like a real IP address
|
|
61
|
+
import json # Converts Python data to/from JSON text
|
|
62
|
+
import logging # Writes helpful log messages so we can see what's happening
|
|
63
|
+
import socket # The main tool for talking over a network
|
|
64
|
+
import subprocess # Used to run clear-screen commands safely
|
|
65
|
+
import sys # Used to exit the program cleanly
|
|
66
|
+
import threading # Lets the program do two things at the same time
|
|
67
|
+
|
|
68
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
# LOGGING SETUP — this records what the program is doing behind the scenes
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
# Logging is like a diary the program keeps. It writes down important events
|
|
73
|
+
# so that if something goes wrong, you can look back and figure out what
|
|
74
|
+
# happened. We set up a file handler after we know the username so each
|
|
75
|
+
# instance writes to its own log file (e.g., chat_server.log, chat_client.log).
|
|
76
|
+
|
|
77
|
+
logging.basicConfig(
|
|
78
|
+
level=logging.DEBUG, # Record everything, even tiny details
|
|
79
|
+
format="%(asctime)s [%(levelname)s] %(message)s", # Timestamp + level + message
|
|
80
|
+
handlers=[], # Handlers will be added later in main() once we know the username
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
log = logging.getLogger(__name__) # Get a logger named after this file
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def setup_logging(username):
|
|
87
|
+
"""Set up file logging after we know the username.
|
|
88
|
+
|
|
89
|
+
Think of this like giving each chat person their own notebook.
|
|
90
|
+
Their notebook filename is based on their username.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
log_file = f"chat_{username.lower()}.log"
|
|
94
|
+
file_handler = logging.FileHandler(log_file)
|
|
95
|
+
file_handler.setLevel(logging.DEBUG)
|
|
96
|
+
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
97
|
+
file_handler.setFormatter(formatter)
|
|
98
|
+
log.addHandler(file_handler)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
# CONSTANTS — fixed values we use throughout the program
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
DEFAULT_PORT = 54321 # The "door number" both sides agree to use
|
|
106
|
+
BUFFER_SIZE = 4096 # How many bytes we read from the network at once
|
|
107
|
+
ENCODING = "utf-8" # The text encoding used when sending messages
|
|
108
|
+
|
|
109
|
+
APP_NAME = "auldchat"
|
|
110
|
+
|
|
111
|
+
# ANSI escape codes for colouring usernames in the terminal.
|
|
112
|
+
# \033[Xm sets a colour; \033[0m resets back to normal.
|
|
113
|
+
_RESET = "\033[0m"
|
|
114
|
+
_COLOUR_THEM = "\033[96m" # Bright cyan — the other person's name
|
|
115
|
+
_COLOUR_ME = "\033[92m" # Bright green — your own name (echo)
|
|
116
|
+
|
|
117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
# HELPER: get_auldchat_version
|
|
119
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_auldchat_version() -> str:
|
|
123
|
+
"""Import this app's version number from the __init__.py file, which
|
|
124
|
+
dynamically loads the number at runtime.""" # TODO: more explanation
|
|
125
|
+
|
|
126
|
+
from auldchat import __version__
|
|
127
|
+
|
|
128
|
+
return __version__
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
# HELPER: get_network_interfaces
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_network_interfaces():
|
|
137
|
+
"""Build a list of (name, ip_address) pairs for this computer.
|
|
138
|
+
|
|
139
|
+
A "network interface" is basically a way your computer is connected to a
|
|
140
|
+
network — for example, via Wi-Fi or an Ethernet cable. Each one gets its
|
|
141
|
+
own IP address, which is like a street address on the network.
|
|
142
|
+
|
|
143
|
+
We use a small trick here: we briefly "connect" a UDP socket to a public
|
|
144
|
+
address just to ask the OS which local IP it would use. We never actually
|
|
145
|
+
send any data — it's just a way to discover the IP."""
|
|
146
|
+
|
|
147
|
+
interfaces = []
|
|
148
|
+
seen_ips = set() # Helps us avoid listing the same IP twice.
|
|
149
|
+
|
|
150
|
+
def add_interface(name, ip):
|
|
151
|
+
# Explicitly avoid wildcard addresses. This program requires binding
|
|
152
|
+
# to a specific interface address. Wildcards mean "all addresses",
|
|
153
|
+
# but for this learning app we want one clear, specific address.
|
|
154
|
+
if ip in {"0.0.0.0", "::"}:
|
|
155
|
+
return
|
|
156
|
+
if ip not in seen_ips:
|
|
157
|
+
interfaces.append((name, ip))
|
|
158
|
+
seen_ips.add(ip)
|
|
159
|
+
|
|
160
|
+
# Now try to find the specific local IP addresses.
|
|
161
|
+
try:
|
|
162
|
+
# We open a UDP socket (kind of like a postbox) and "connect" it to
|
|
163
|
+
# Google's DNS server. This doesn't actually send anything — the OS
|
|
164
|
+
# just figures out which local address it would use.
|
|
165
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
166
|
+
s.connect(("8.8.8.8", 80))
|
|
167
|
+
local_ip = s.getsockname()[0]
|
|
168
|
+
add_interface("Local network IPv4 (auto-detected)", local_ip)
|
|
169
|
+
except (OSError, socket.error):
|
|
170
|
+
# TODO: the above code might raise a variety of Exceptions. Add them
|
|
171
|
+
# and specifically handle them and log them.
|
|
172
|
+
#
|
|
173
|
+
# If the above trick fails (e.g., no internet), that's fine — we still
|
|
174
|
+
# can continue with any other addresses we discover. In coding, it's
|
|
175
|
+
# okay for one plan to fail if we have backups.
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Try to discover a reachable IPv6 address using the same UDP trick.
|
|
179
|
+
try:
|
|
180
|
+
with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s6:
|
|
181
|
+
s6.connect(("2001:4860:4860::8888", 80, 0, 0))
|
|
182
|
+
local_ip6 = s6.getsockname()[0]
|
|
183
|
+
add_interface("Local network IPv6 (auto-detected)", local_ip6)
|
|
184
|
+
except (OSError, socket.error):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Also add loopback — this only works if both people are on the SAME
|
|
188
|
+
# computer, which is useful for testing.
|
|
189
|
+
add_interface("Loopback IPv4 (same computer only)", "127.0.0.1")
|
|
190
|
+
add_interface("Loopback IPv6 (same computer only)", "::1")
|
|
191
|
+
|
|
192
|
+
# If everything failed above, still offer local loopback IPv4.
|
|
193
|
+
if not interfaces:
|
|
194
|
+
interfaces.append(("Loopback IPv4 (same computer only)", "127.0.0.1"))
|
|
195
|
+
|
|
196
|
+
return interfaces
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_wildcard_address(host):
|
|
200
|
+
"""Return True if host means "all addresses" (0.0.0.0 or ::).
|
|
201
|
+
|
|
202
|
+
Those special addresses are useful in many apps, but this program asks
|
|
203
|
+
kids to pick one exact local address so the behavior stays clear.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
return ipaddress.ip_address(host).is_unspecified
|
|
208
|
+
except ValueError:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def format_endpoint(host, port):
|
|
213
|
+
"""Format host+port text, adding [brackets] for IPv6 hosts.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
IPv4: 192.168.1.5:54321
|
|
217
|
+
IPv6: [fe80::1]:54321
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
if ":" in host:
|
|
221
|
+
return f"[{host}]:{port}"
|
|
222
|
+
return f"{host}:{port}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
# HELPER: pick_interface
|
|
227
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def pick_interface():
|
|
231
|
+
"""Displays all available network interfaces and asks the user to choose
|
|
232
|
+
one. Returns the IP address string for the chosen interface.
|
|
233
|
+
|
|
234
|
+
This is shown only when running as a server, so the user can decide which
|
|
235
|
+
network address to listen on."""
|
|
236
|
+
|
|
237
|
+
interfaces = get_network_interfaces()
|
|
238
|
+
|
|
239
|
+
print("\nHere are your network interfaces (connections):")
|
|
240
|
+
print("─" * 40)
|
|
241
|
+
|
|
242
|
+
for index, (name, ip) in enumerate(interfaces):
|
|
243
|
+
# Pretty alignment (:35s) keeps the menu easy to read.
|
|
244
|
+
print(f" [{index}] {name:35s} {ip}")
|
|
245
|
+
|
|
246
|
+
print("─" * 40)
|
|
247
|
+
|
|
248
|
+
while True:
|
|
249
|
+
try:
|
|
250
|
+
choice = input("Pick an interface by number [0]: ").strip()
|
|
251
|
+
|
|
252
|
+
# If the user just presses Enter, default to option 0.
|
|
253
|
+
if choice == "":
|
|
254
|
+
choice = "0"
|
|
255
|
+
|
|
256
|
+
# Turn what the user typed (text) into a number.
|
|
257
|
+
choice_int = int(choice)
|
|
258
|
+
|
|
259
|
+
if 0 <= choice_int < len(interfaces):
|
|
260
|
+
name, ip = interfaces[choice_int]
|
|
261
|
+
log.info("User chose interface: %s (%s)", name, ip)
|
|
262
|
+
print(f'\nGreat! I will "bind to" (listen on) this interface: {ip}\n')
|
|
263
|
+
return ip
|
|
264
|
+
else:
|
|
265
|
+
print(f"Please enter a number between 0 and {len(interfaces) - 1}.")
|
|
266
|
+
|
|
267
|
+
except ValueError:
|
|
268
|
+
print("That doesn't look like a number. Please try again.")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
# HELPER: send_message
|
|
273
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def send_message(sock, username, text):
|
|
277
|
+
"""Packages a chat message as a JSON line and sends it over the socket.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
sock : the connected socket to send data through
|
|
282
|
+
username : the sender's display name (e.g. "Dad" or "Kid")
|
|
283
|
+
text : the message text to send
|
|
284
|
+
|
|
285
|
+
JSON format used:
|
|
286
|
+
{"username": "Dad", "text": "Hello!"}
|
|
287
|
+
|
|
288
|
+
We add a newline character (\\n) at the end so the receiver knows exactly
|
|
289
|
+
where this message ends and the next one begins."""
|
|
290
|
+
|
|
291
|
+
payload = {"username": username, "text": text}
|
|
292
|
+
line = json.dumps(payload) + "\n" # Serialize to a JSON line
|
|
293
|
+
encoded = line.encode(ENCODING) # Convert text to bytes
|
|
294
|
+
sock.sendall(encoded) # Send every byte
|
|
295
|
+
log.debug("Sent message: %s", payload)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
# HELPER: receive_loop
|
|
300
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def receive_loop(sock, stop_event):
|
|
304
|
+
"""Runs in a background thread. Continuously reads incoming data from the
|
|
305
|
+
socket and prints new messages to the screen as they arrive.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
sock : the connected socket to read from
|
|
310
|
+
stop_event : a threading.Event that signals this loop to stop
|
|
311
|
+
|
|
312
|
+
How reading works
|
|
313
|
+
-----------------
|
|
314
|
+
Data arrives over the network in chunks (up to BUFFER_SIZE bytes at a
|
|
315
|
+
time). We collect those chunks in a "buffer" string. Whenever we see a
|
|
316
|
+
newline character (\\n), we know we have a complete JSON message, so we
|
|
317
|
+
cut it out and decode it. This handles the case where the network splits
|
|
318
|
+
one message across multiple chunks, or bundles two messages into one
|
|
319
|
+
chunk."""
|
|
320
|
+
|
|
321
|
+
buffer = "" # Holds partial message pieces between reads
|
|
322
|
+
|
|
323
|
+
while not stop_event.is_set():
|
|
324
|
+
try:
|
|
325
|
+
# Read a chunk of bytes from the network.
|
|
326
|
+
data = sock.recv(BUFFER_SIZE)
|
|
327
|
+
|
|
328
|
+
if not data:
|
|
329
|
+
# An empty read means the other side has closed the connection.
|
|
330
|
+
print("\n[The other person has disconnected.]\n> ", end="", flush=True)
|
|
331
|
+
log.info("Remote side closed the connection.")
|
|
332
|
+
stop_event.set()
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
# Decode bytes to a string and add to our running buffer.
|
|
336
|
+
buffer += data.decode(ENCODING)
|
|
337
|
+
|
|
338
|
+
# Process every complete line (complete JSON message) in the buffer.
|
|
339
|
+
while "\n" in buffer:
|
|
340
|
+
# Take one complete line, keep any leftover part for later.
|
|
341
|
+
line, buffer = buffer.split("\n", 1)
|
|
342
|
+
line = line.strip()
|
|
343
|
+
|
|
344
|
+
if not line:
|
|
345
|
+
continue # Skip blank lines
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
message = json.loads(line) # Turn JSON text back into a dict
|
|
349
|
+
username = message.get("username", "???")
|
|
350
|
+
text = message.get("text", "")
|
|
351
|
+
log.debug("Received message: %s", message)
|
|
352
|
+
|
|
353
|
+
# Print the message, then re-print the input prompt so the
|
|
354
|
+
# user knows they can still type.
|
|
355
|
+
print(
|
|
356
|
+
f"\r{_COLOUR_THEM}{username}{_RESET}: {text}\n> ",
|
|
357
|
+
end="",
|
|
358
|
+
flush=True,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
except json.JSONDecodeError:
|
|
362
|
+
# If the JSON is broken, log it but don't crash.
|
|
363
|
+
log.warning("Received malformed JSON: %r", line)
|
|
364
|
+
|
|
365
|
+
except OSError:
|
|
366
|
+
# The socket was closed (probably because we quit). Stop quietly.
|
|
367
|
+
if not stop_event.is_set():
|
|
368
|
+
log.warning("Socket error in receive_loop; stopping.")
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
# HELPER: handle_slash_command
|
|
374
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def handle_slash_command(command, sock, username, stop_event):
|
|
378
|
+
"""Handles a slash command entered by the user.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
command : the command string (e.g. "/quit")
|
|
383
|
+
sock : the connected socket (used by /status)
|
|
384
|
+
username : our own display name
|
|
385
|
+
stop_event : set to True if the user wants to quit
|
|
386
|
+
|
|
387
|
+
Returns True if the program should exit, False otherwise."""
|
|
388
|
+
|
|
389
|
+
command = command.strip().lower()
|
|
390
|
+
|
|
391
|
+
if command == "/help":
|
|
392
|
+
print(
|
|
393
|
+
"\nCommands you can type:\n"
|
|
394
|
+
" /help — Show this command list\n"
|
|
395
|
+
" /status — Show who is connected\n"
|
|
396
|
+
" /clear — Wipe the screen clean\n"
|
|
397
|
+
" /quit — Leave the chat\n"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
elif command == "/status":
|
|
401
|
+
try:
|
|
402
|
+
peer = sock.getpeername() # The other side's address tuple: (IP, port)
|
|
403
|
+
me = sock.getsockname() # Our own address tuple: (IP, port)
|
|
404
|
+
print(
|
|
405
|
+
f"\nConnection status:\n"
|
|
406
|
+
f" Your username : {username}\n"
|
|
407
|
+
f" Your address : {me[0]}:{me[1]}\n"
|
|
408
|
+
f" Their address : {peer[0]}:{peer[1]}\n"
|
|
409
|
+
)
|
|
410
|
+
log.info("Status requested. me=%s peer=%s", me, peer)
|
|
411
|
+
except OSError:
|
|
412
|
+
print("\n[You are not connected right now.]\n")
|
|
413
|
+
|
|
414
|
+
elif command == "/clear":
|
|
415
|
+
# Clear the terminal screen. "cls" is for Windows; "clear" is for Mac/Linux.
|
|
416
|
+
command_name = "cls" if sys.platform.startswith("win") else "clear"
|
|
417
|
+
try:
|
|
418
|
+
subprocess.run(command_name, check=False, shell=True)
|
|
419
|
+
except OSError as e:
|
|
420
|
+
log.warning("Failed to clear screen with %s: %s", command_name, e)
|
|
421
|
+
|
|
422
|
+
elif command == "/quit":
|
|
423
|
+
print("\n[Leaving chat now. Bye!]\n")
|
|
424
|
+
log.info("User issued /quit.")
|
|
425
|
+
stop_event.set()
|
|
426
|
+
return True # Signal the REPL to exit
|
|
427
|
+
|
|
428
|
+
else:
|
|
429
|
+
print(f"\n[I don't know {command!r}. Type /help to see commands.]\n")
|
|
430
|
+
|
|
431
|
+
return False # Keep the REPL running
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
435
|
+
# CORE: repl
|
|
436
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def repl(sock, username):
|
|
440
|
+
"""The main chat loop — REPL stands for Read, Evaluate, Print, Loop.
|
|
441
|
+
This is the part of the program the user actually interacts with.
|
|
442
|
+
|
|
443
|
+
It:
|
|
444
|
+
1. Starts a background thread to receive incoming messages.
|
|
445
|
+
2. Reads lines the user types.
|
|
446
|
+
3. If the line starts with '/', treats it as a slash command.
|
|
447
|
+
4. Otherwise, sends it as a chat message.
|
|
448
|
+
5. Stops when the user types /quit or the connection drops.
|
|
449
|
+
|
|
450
|
+
Parameters
|
|
451
|
+
----------
|
|
452
|
+
sock : the connected socket for this chat session
|
|
453
|
+
username : our display name
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
stop_event = threading.Event() # Shared "stop now" flag for both threads
|
|
457
|
+
|
|
458
|
+
# Start the background thread that listens for incoming messages.
|
|
459
|
+
receiver = threading.Thread(
|
|
460
|
+
target=receive_loop,
|
|
461
|
+
args=(sock, stop_event),
|
|
462
|
+
daemon=True, # Daemon threads are killed automatically when the main
|
|
463
|
+
# program exits, so we don't have to clean them up manually.
|
|
464
|
+
)
|
|
465
|
+
receiver.start()
|
|
466
|
+
log.info("Receive thread started.")
|
|
467
|
+
|
|
468
|
+
print(
|
|
469
|
+
"\n[Connected! Type a message and press Enter. Type /help if you get stuck.]\n"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
while not stop_event.is_set():
|
|
474
|
+
try:
|
|
475
|
+
# Show a prompt and wait for the user to type something.
|
|
476
|
+
line = input("> ").strip()
|
|
477
|
+
except EOFError:
|
|
478
|
+
# EOFError happens if the input stream closes (e.g. Ctrl+D on Mac/Linux).
|
|
479
|
+
break
|
|
480
|
+
except KeyboardInterrupt:
|
|
481
|
+
# Ctrl+C — treat it like /quit.
|
|
482
|
+
print()
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
if not line:
|
|
486
|
+
continue # The user just pressed Enter without typing anything.
|
|
487
|
+
|
|
488
|
+
if line.startswith("/"):
|
|
489
|
+
# It's a slash command — handle it locally.
|
|
490
|
+
should_quit = handle_slash_command(line, sock, username, stop_event)
|
|
491
|
+
if should_quit:
|
|
492
|
+
break
|
|
493
|
+
else:
|
|
494
|
+
# It's a regular message — send it to the other person.
|
|
495
|
+
try:
|
|
496
|
+
send_message(sock, username, line)
|
|
497
|
+
# Echo our own message back to the screen so we can see it.
|
|
498
|
+
print(
|
|
499
|
+
f"\r{_COLOUR_ME}{username}{_RESET}: {line}\n> ",
|
|
500
|
+
end="",
|
|
501
|
+
flush=True,
|
|
502
|
+
)
|
|
503
|
+
except OSError:
|
|
504
|
+
print(
|
|
505
|
+
"\n[Your message could not send because the connection was lost.]\n"
|
|
506
|
+
)
|
|
507
|
+
log.error("Failed to send message; connection may be lost.")
|
|
508
|
+
break
|
|
509
|
+
|
|
510
|
+
finally:
|
|
511
|
+
# No matter how we exit the loop, clean up the socket and stop the thread.
|
|
512
|
+
stop_event.set()
|
|
513
|
+
try:
|
|
514
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
515
|
+
except OSError:
|
|
516
|
+
pass
|
|
517
|
+
sock.close()
|
|
518
|
+
log.info("Socket closed. REPL exited.")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
522
|
+
# MODE: run_server
|
|
523
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def run_server(host, port, username):
|
|
527
|
+
"""Runs the program in SERVER mode.
|
|
528
|
+
|
|
529
|
+
The server:
|
|
530
|
+
1. Asks which network interface to bind to (if host wasn't given).
|
|
531
|
+
2. Opens a "listening" socket — like putting up a sign that says
|
|
532
|
+
"I'm here, come find me!"
|
|
533
|
+
3. Waits for exactly one client to connect.
|
|
534
|
+
4. Starts the chat REPL once connected.
|
|
535
|
+
|
|
536
|
+
Parameters
|
|
537
|
+
----------
|
|
538
|
+
host : IP address to bind to (or None to ask the user interactively)
|
|
539
|
+
port : TCP port number to listen on
|
|
540
|
+
username : our display name
|
|
541
|
+
"""
|
|
542
|
+
# If no host was specified on the command line, ask the user interactively.
|
|
543
|
+
if host is None:
|
|
544
|
+
host = pick_interface()
|
|
545
|
+
|
|
546
|
+
if is_wildcard_address(host):
|
|
547
|
+
print(
|
|
548
|
+
"\n[Binding to all addresses (0.0.0.0 or ::) is disabled. Choose a specific local IP.]\n"
|
|
549
|
+
)
|
|
550
|
+
log.error("Refused wildcard bind address: %s", host)
|
|
551
|
+
sys.exit(1)
|
|
552
|
+
|
|
553
|
+
log.info("Starting server on %s:%d as '%s'", host, port, username)
|
|
554
|
+
|
|
555
|
+
server_sock = None
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
# Ask Python/OS for all ways this host could work (IPv4 or IPv6),
|
|
559
|
+
# then try each one until one succeeds.
|
|
560
|
+
bind_candidates = socket.getaddrinfo(
|
|
561
|
+
host, port, socket.AF_UNSPEC, socket.SOCK_STREAM
|
|
562
|
+
)
|
|
563
|
+
last_error = None
|
|
564
|
+
for family, socktype, proto, _, sockaddr in bind_candidates:
|
|
565
|
+
candidate = None
|
|
566
|
+
try:
|
|
567
|
+
# Build a socket that matches this candidate's family/protocol.
|
|
568
|
+
candidate = socket.socket(family, socktype, proto)
|
|
569
|
+
candidate.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
570
|
+
candidate.bind(sockaddr)
|
|
571
|
+
server_sock = candidate
|
|
572
|
+
break
|
|
573
|
+
except OSError as e:
|
|
574
|
+
last_error = e
|
|
575
|
+
try:
|
|
576
|
+
if candidate is not None:
|
|
577
|
+
candidate.close()
|
|
578
|
+
except OSError:
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
if server_sock is None:
|
|
582
|
+
if last_error is not None:
|
|
583
|
+
raise last_error
|
|
584
|
+
raise OSError("No compatible address found for bind.")
|
|
585
|
+
|
|
586
|
+
server_sock.listen(1) # Allow only 1 pending connection at a time
|
|
587
|
+
|
|
588
|
+
print(f"\nServer is listening on {format_endpoint(host, port)}")
|
|
589
|
+
print("Tell the other person to connect to this computer's IP address.")
|
|
590
|
+
print("Waiting for a connection...\n")
|
|
591
|
+
|
|
592
|
+
# We use a short timeout so this loop "wakes up" often. That lets
|
|
593
|
+
# Ctrl+C feel responsive instead of making us wait a long time.
|
|
594
|
+
server_sock.settimeout(1.0)
|
|
595
|
+
while True:
|
|
596
|
+
try:
|
|
597
|
+
conn, addr = server_sock.accept()
|
|
598
|
+
break
|
|
599
|
+
except socket.timeout:
|
|
600
|
+
# No incoming client yet; loop and wait again.
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
log.info("Client connected from %s:%d", addr[0], addr[1])
|
|
604
|
+
print(f"[{addr[0]} has connected! Let's chat.]\n")
|
|
605
|
+
|
|
606
|
+
# Hand off the connected socket to the REPL.
|
|
607
|
+
repl(conn, username)
|
|
608
|
+
|
|
609
|
+
except OSError as e:
|
|
610
|
+
log.error("Server error: %s", e)
|
|
611
|
+
print(f"\n[Server error: {e}]\n")
|
|
612
|
+
sys.exit(1)
|
|
613
|
+
|
|
614
|
+
except KeyboardInterrupt:
|
|
615
|
+
print("\n[Shutting down server. Goodbye!]\n")
|
|
616
|
+
log.info("Server shutdown requested by user (KeyboardInterrupt).")
|
|
617
|
+
|
|
618
|
+
finally:
|
|
619
|
+
if server_sock is not None:
|
|
620
|
+
server_sock.close()
|
|
621
|
+
log.info("Server socket closed.")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
625
|
+
# MODE: run_client
|
|
626
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def run_client(host, port, username):
|
|
630
|
+
"""Runs the program in CLIENT mode.
|
|
631
|
+
|
|
632
|
+
The client:
|
|
633
|
+
1. Creates a socket and connects to the server's IP address and port.
|
|
634
|
+
2. Starts the chat REPL once connected.
|
|
635
|
+
|
|
636
|
+
Parameters
|
|
637
|
+
----------
|
|
638
|
+
host : IP address of the server to connect to (required)
|
|
639
|
+
port : TCP port number the server is listening on
|
|
640
|
+
username : our display name
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
log.info("Connecting to server at %s:%d as '%s'", host, port, username)
|
|
644
|
+
print(f"\nTrying to connect to {format_endpoint(host, port)}...")
|
|
645
|
+
|
|
646
|
+
# This will hold the successfully connected socket once we have one.
|
|
647
|
+
sock = None
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
# Keep trying to connect in short attempts, so Ctrl+C works quickly.
|
|
651
|
+
while True:
|
|
652
|
+
# Create a fresh socket for each attempt. On Windows, reusing a
|
|
653
|
+
# socket after a timed-out connect can trigger WinError 10022.
|
|
654
|
+
attempt_sock = None
|
|
655
|
+
try:
|
|
656
|
+
# Like server mode, ask for all possible address styles first.
|
|
657
|
+
connect_candidates = socket.getaddrinfo(
|
|
658
|
+
host,
|
|
659
|
+
port,
|
|
660
|
+
socket.AF_UNSPEC,
|
|
661
|
+
socket.SOCK_STREAM,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
last_error = None
|
|
665
|
+
for family, socktype, proto, _, sockaddr in connect_candidates:
|
|
666
|
+
try:
|
|
667
|
+
attempt_sock = socket.socket(family, socktype, proto)
|
|
668
|
+
# Keep each connect attempt short so Ctrl+C stays responsive.
|
|
669
|
+
attempt_sock.settimeout(1.0)
|
|
670
|
+
attempt_sock.connect(sockaddr) # Reach out to the server
|
|
671
|
+
attempt_sock.settimeout(
|
|
672
|
+
None
|
|
673
|
+
) # Use normal blocking mode in chat
|
|
674
|
+
sock = attempt_sock
|
|
675
|
+
break
|
|
676
|
+
except socket.timeout:
|
|
677
|
+
last_error = socket.timeout()
|
|
678
|
+
if attempt_sock is not None:
|
|
679
|
+
attempt_sock.close()
|
|
680
|
+
attempt_sock = None
|
|
681
|
+
continue
|
|
682
|
+
except OSError as e:
|
|
683
|
+
last_error = e
|
|
684
|
+
if attempt_sock is not None:
|
|
685
|
+
attempt_sock.close()
|
|
686
|
+
attempt_sock = None
|
|
687
|
+
continue
|
|
688
|
+
|
|
689
|
+
if sock is not None:
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
if last_error is not None:
|
|
693
|
+
raise last_error
|
|
694
|
+
raise OSError("No compatible address found for connect.")
|
|
695
|
+
except socket.timeout:
|
|
696
|
+
# Server didn't answer within this one-second attempt.
|
|
697
|
+
# Close this socket and retry with a new one.
|
|
698
|
+
if attempt_sock is not None:
|
|
699
|
+
attempt_sock.close()
|
|
700
|
+
continue
|
|
701
|
+
except Exception:
|
|
702
|
+
# Any non-timeout error: clean up and let outer handlers decide.
|
|
703
|
+
if attempt_sock is not None:
|
|
704
|
+
attempt_sock.close()
|
|
705
|
+
raise
|
|
706
|
+
|
|
707
|
+
log.info("Connected to server at %s:%d", host, port)
|
|
708
|
+
print(f"[You are connected to {format_endpoint(host, port)}]")
|
|
709
|
+
|
|
710
|
+
# Hand off the connected socket to the REPL.
|
|
711
|
+
repl(sock, username)
|
|
712
|
+
|
|
713
|
+
except ConnectionRefusedError:
|
|
714
|
+
print(f"\n[Connection was refused. Is a server running at {host}:{port}?]\n")
|
|
715
|
+
log.error("Connection refused to %s:%d", host, port)
|
|
716
|
+
sys.exit(1)
|
|
717
|
+
|
|
718
|
+
except OSError as e:
|
|
719
|
+
print(f"\n[Connection error: {e}]\n")
|
|
720
|
+
log.error("Client connection error: %s", e)
|
|
721
|
+
sys.exit(1)
|
|
722
|
+
|
|
723
|
+
except KeyboardInterrupt:
|
|
724
|
+
print("\n[Connection cancelled. Goodbye!]\n")
|
|
725
|
+
log.info("Client connection cancelled by user (KeyboardInterrupt).")
|
|
726
|
+
|
|
727
|
+
finally:
|
|
728
|
+
# If we connected but exited before REPL ownership fully transfers,
|
|
729
|
+
# ensure the socket is not leaked.
|
|
730
|
+
try:
|
|
731
|
+
if sock is not None:
|
|
732
|
+
sock.close()
|
|
733
|
+
except OSError:
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def prompt_for_mode():
|
|
738
|
+
"""Prompt the user to choose server or client mode."""
|
|
739
|
+
|
|
740
|
+
print("\nYou did not choose a mode yet.")
|
|
741
|
+
while True:
|
|
742
|
+
choice = input("Run as server or client? [server/client]: ").strip().lower()
|
|
743
|
+
if choice in {"server", "client"}:
|
|
744
|
+
return choice
|
|
745
|
+
print("Please type 'server' or 'client'.")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def prompt_for_client_host():
|
|
749
|
+
"""Prompt the user for the server host when client --host was omitted."""
|
|
750
|
+
|
|
751
|
+
print("\nClient mode needs a server address.")
|
|
752
|
+
while True:
|
|
753
|
+
host = input("Enter the server IP or hostname (IPv4 or IPv6): ").strip()
|
|
754
|
+
if host:
|
|
755
|
+
return host
|
|
756
|
+
print("Please enter something for the host.")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
760
|
+
# ENTRY POINT — this is where the program starts when you run it
|
|
761
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def main():
|
|
765
|
+
"""Parses command-line arguments and launches either the server or the
|
|
766
|
+
client.
|
|
767
|
+
|
|
768
|
+
argparse is a Python module that reads the words you type after the script
|
|
769
|
+
name and turns them into variables the program can use."""
|
|
770
|
+
|
|
771
|
+
# Create the top-level argument parser.
|
|
772
|
+
parser = argparse.ArgumentParser(
|
|
773
|
+
prog=APP_NAME,
|
|
774
|
+
description=(
|
|
775
|
+
"A simple two-person command-line chat app. "
|
|
776
|
+
"One person runs server, the other runs client."
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
parser.add_argument(
|
|
780
|
+
"--version",
|
|
781
|
+
action="version",
|
|
782
|
+
version=f"%(prog)s {get_auldchat_version()}",
|
|
783
|
+
help="Show the app version, then exit.",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# The app has two modes: "server" and "client".
|
|
787
|
+
# In argparse, these are called "subcommands".
|
|
788
|
+
subparsers = parser.add_subparsers(dest="mode")
|
|
789
|
+
|
|
790
|
+
# ── SERVER subcommand ──────────────────────────────────────────────────
|
|
791
|
+
server_parser = subparsers.add_parser(
|
|
792
|
+
"server",
|
|
793
|
+
help="Start a chat server and wait for someone to connect.",
|
|
794
|
+
)
|
|
795
|
+
server_parser.add_argument(
|
|
796
|
+
"--host",
|
|
797
|
+
default=None,
|
|
798
|
+
metavar="IP",
|
|
799
|
+
help=(
|
|
800
|
+
"Specific IP address to listen on (IPv4 or IPv6). If you skip this, "
|
|
801
|
+
"you will choose from a list."
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
server_parser.add_argument(
|
|
805
|
+
"--port",
|
|
806
|
+
type=int,
|
|
807
|
+
default=DEFAULT_PORT,
|
|
808
|
+
metavar="PORT",
|
|
809
|
+
help=f"Port number to listen on (default: {DEFAULT_PORT}).",
|
|
810
|
+
)
|
|
811
|
+
server_parser.add_argument(
|
|
812
|
+
"--username",
|
|
813
|
+
default="Server",
|
|
814
|
+
metavar="NAME",
|
|
815
|
+
help="Your chat name (default: Server).",
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# ── CLIENT subcommand ──────────────────────────────────────────────────
|
|
819
|
+
client_parser = subparsers.add_parser(
|
|
820
|
+
"client",
|
|
821
|
+
help="Connect to a chat server.",
|
|
822
|
+
)
|
|
823
|
+
client_parser.add_argument(
|
|
824
|
+
"--host",
|
|
825
|
+
default=None,
|
|
826
|
+
metavar="IP",
|
|
827
|
+
help="Server IP/hostname to connect to. If omitted, you will be asked.",
|
|
828
|
+
)
|
|
829
|
+
client_parser.add_argument(
|
|
830
|
+
"--port",
|
|
831
|
+
type=int,
|
|
832
|
+
default=DEFAULT_PORT,
|
|
833
|
+
metavar="PORT",
|
|
834
|
+
help=f"Port number the server is using (default: {DEFAULT_PORT}).",
|
|
835
|
+
)
|
|
836
|
+
client_parser.add_argument(
|
|
837
|
+
"--username",
|
|
838
|
+
default="Client",
|
|
839
|
+
metavar="NAME",
|
|
840
|
+
help="Your chat name (default: Client).",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
# Parse the arguments the user typed.
|
|
844
|
+
args = parser.parse_args()
|
|
845
|
+
|
|
846
|
+
# If no subcommand was given, ask interactively.
|
|
847
|
+
if args.mode is None:
|
|
848
|
+
args.mode = prompt_for_mode()
|
|
849
|
+
|
|
850
|
+
# If user chose mode interactively, make sure missing values get defaults.
|
|
851
|
+
if args.mode == "server":
|
|
852
|
+
if not hasattr(args, "host"):
|
|
853
|
+
args.host = None
|
|
854
|
+
if not hasattr(args, "port"):
|
|
855
|
+
args.port = DEFAULT_PORT
|
|
856
|
+
if not hasattr(args, "username"):
|
|
857
|
+
args.username = "Server"
|
|
858
|
+
elif args.mode == "client":
|
|
859
|
+
if not hasattr(args, "host"):
|
|
860
|
+
args.host = None
|
|
861
|
+
if not hasattr(args, "port"):
|
|
862
|
+
args.port = DEFAULT_PORT
|
|
863
|
+
if not hasattr(args, "username"):
|
|
864
|
+
args.username = "Client"
|
|
865
|
+
if not args.host:
|
|
866
|
+
args.host = prompt_for_client_host()
|
|
867
|
+
|
|
868
|
+
# Set up logging with the username so each instance has its own log file.
|
|
869
|
+
setup_logging(args.username)
|
|
870
|
+
|
|
871
|
+
log.info("Program started. mode=%s username=%s", args.mode, args.username)
|
|
872
|
+
|
|
873
|
+
# Dispatch to the appropriate function based on the chosen mode.
|
|
874
|
+
if args.mode == "server":
|
|
875
|
+
run_server(args.host, args.port, args.username)
|
|
876
|
+
elif args.mode == "client":
|
|
877
|
+
run_client(args.host, args.port, args.username)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# This block ensures main() is only called when the script is run directly,
|
|
881
|
+
# not when it is imported as a module by another Python file.
|
|
882
|
+
if __name__ == "__main__":
|
|
883
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auldchat
|
|
3
|
+
Version: 2026.3.22.0
|
|
4
|
+
Summary: A simple two-way command-line chat program.
|
|
5
|
+
Author-email: Alex Lemna <git@alexanderlemna.com>
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
<!--
|
|
10
|
+
SPDX-FileCopyrightText: © 2026 Alexander Lloyd Lemna
|
|
11
|
+
SPDX-License-Identifier: CC0-1.0
|
|
12
|
+
-->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
auldchat/__init__.py,sha256=LuqGaspB0YkTzEQfGrHvQe8RgNzSVo8uC2evT34v0SE,306
|
|
2
|
+
auldchat/__main__.py,sha256=abeBmeBJbc4IjkcESMkz5cd1KGakyMolobY54lBhi-4,36856
|
|
3
|
+
auldchat-2026.3.22.0.dist-info/METADATA,sha256=rLX5LGJDpOwKg1NWsU0GbEFNih_fz-D-n3vewcjnLdc,334
|
|
4
|
+
auldchat-2026.3.22.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
auldchat-2026.3.22.0.dist-info/entry_points.txt,sha256=7xHx57kkTkCwmD9BrttaUDCAPXEdYaPG40Eh-zHztMA,52
|
|
6
|
+
auldchat-2026.3.22.0.dist-info/top_level.txt,sha256=YSIW3NgX842l6coieRIAMmUh96h1_BhPYnxfEC9Cq4Q,9
|
|
7
|
+
auldchat-2026.3.22.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auldchat
|