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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ auldchat = auldchat.__main__:main
@@ -0,0 +1 @@
1
+ auldchat