From 9a4e02062af7f1b90d97631b7f41102582cf4643 Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 12:19:11 -0700 Subject: [PATCH 1/6] Add advanced usage of system packages in virtual envinornments. --- python/doc/venv.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/python/doc/venv.md b/python/doc/venv.md index 6daf04d..e877b38 100644 --- a/python/doc/venv.md +++ b/python/doc/venv.md @@ -1,5 +1,9 @@ # Python Virtual Environment +# BASIC + +This is the basic workflow for using a virtual environment: + ```bash mkdir ./venv python -m venv venv @@ -9,4 +13,24 @@ pip install -r requirements.txt . . deactivate -``` \ No newline at end of file +``` + +# ADVANCED + +You may find it necessary to install some Python packages at the system level. + +`tkinter` is one such example. `Pillow` is one you can install via `pip` but you'll probably want to do all these at once: + +```bash +sudo apt update +sudo apt install python3-tk python3-pil python3-pil.imagetk +``` + +The problem is that site packages are not visible to a `venv`. Therefore, you'll have to bring them into your `venv` when you create it via the `--system-site-packages` option. + +```bash +python3 -m venv --system-site-packages venv_name +source venv_name/bin/activate +pip install -r requirements.txt # any other deps +``` + From d5d885d6a698ec265a9f7c70d443c1944f4d0993 Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 13:42:19 -0700 Subject: [PATCH 2/6] Maintenance (ANTIALIAS->LANCZOS) and documentation update (prerequisites approach). --- python/apps/xv.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/python/apps/xv.py b/python/apps/xv.py index 503909f..3cf6d1a 100644 --- a/python/apps/xv.py +++ b/python/apps/xv.py @@ -9,11 +9,6 @@ options: -h, --help show this help message and exit -NOTES: - sudo apt-get install python3-pip - sudo pip3 install pillow - sudo apt-get install python3-pil.imagetk - http://www.trilon.com/xv/ """ import argparse @@ -50,7 +45,17 @@ def __init__(self, filename, master=None): scaled_width = int((scale * float(width))) scaled_height = int((scale * float(height))) - image = image.resize((scaled_width, scaled_height), PIL.Image.ANTIALIAS) + + # Used to use PIL.Image.ANTIALIAS. But that was dropped in + # Pillow 10.0.0. It was an alias to LANCZOS anyway. + try: + RESAMPLE_LANCZOS = PIL.Image.Resampling.LANCZOS + except AttributeError: + # Pillow < 9.1 or so + RESAMPLE_LANCZOS = PIL.Image.LANCZOS + # or PIL.Image.ANTIALIAS if you really need to support ancient versions + + image = image.resize((scaled_width, scaled_height), RESAMPLE_LANCZOS) # Keep a reference to the PhotoImage instance. # (Otherwise the label's image would disappear @@ -68,7 +73,26 @@ def main(args): return 0 if __name__ == '__main__': - parser = argparse.ArgumentParser(description='A simple image viewer and a tribute to the original xv image display editing program for the X Window System.') + parser = argparse.ArgumentParser( + description="""DESCRIPTION + A simple image viewer and a tribute to the original xv image display editing program for the X Window System.""", + epilog="""REQUIREMENTS + One-time system setup: + sudo apt update + sudo apt install python3-tk python3-pil python3-pil.imagetk + + To use this application in a venv, you'll have to bring in + these system packages when you create the venv: + python3 -m venv --system-site-packages venv_name + source venv_name/bin/activate + pip install -r requirements.txt # any other deps + +NOTES + http://www.trilon.com/xv/ (gives 404 as of 6/20/2026) + https://itsfoss.community/t/resurrecting-xv-the-original-linux-image-viewer/12426 + https://github.com/nevillejackson/Unix/tree/main/xv""", + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( 'filename', help='Image file to display.') From a84ad373a3a5bf9cbe48bc607e6509a3242ad51a Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 13:48:43 -0700 Subject: [PATCH 3/6] Update comment block to newest help text. --- python/apps/xv.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/python/apps/xv.py b/python/apps/xv.py index 3cf6d1a..d324937 100644 --- a/python/apps/xv.py +++ b/python/apps/xv.py @@ -1,7 +1,8 @@ r""" usage: xv.py [-h] filename -A simple image viewer and a tribute to the original xv image display editing program for the X Window System. +DESCRIPTION + A simple image viewer and a tribute to the original xv image display editing program for the X Window System. positional arguments: filename Image file to display. @@ -9,6 +10,21 @@ options: -h, --help show this help message and exit +REQUIREMENTS + One-time system setup: + sudo apt update + sudo apt install python3-tk python3-pil python3-pil.imagetk + + To use this application in a venv, you'll have to bring in + these system packages when you create the venv: + python3 -m venv --system-site-packages venv_name + source venv_name/bin/activate + pip install -r requirements.txt # any other deps + +NOTES + http://www.trilon.com/xv/ (gives 404 as of 6/20/2026) + https://itsfoss.community/t/resurrecting-xv-the-original-linux-image-viewer/12426 + https://github.com/nevillejackson/Unix/tree/main/xv """ import argparse From 4c6e4edcefdee92cbd0c358845f18f5755c4929d Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 14:14:37 -0700 Subject: [PATCH 4/6] Peer review feedback + some more hotkeys. --- python/apps/xv.py | 78 ++++++++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/python/apps/xv.py b/python/apps/xv.py index d324937..1b59f53 100644 --- a/python/apps/xv.py +++ b/python/apps/xv.py @@ -2,7 +2,8 @@ usage: xv.py [-h] filename DESCRIPTION - A simple image viewer and a tribute to the original xv image display editing program for the X Window System. + A simple image viewer and a tribute to the original xv image display + editing program for the X Window System. positional arguments: filename Image file to display. @@ -28,59 +29,71 @@ """ import argparse +import os import sys import tkinter -import PIL.Image -import PIL.ImageTk +from PIL import Image, ImageTk, UnidentifiedImageError class Window(tkinter.Tk): def __init__(self, master=None): tkinter.Tk.__init__(self, master) self.bind('', self.escape) + self.bind('', self.escape) # classic quit + self.bind('', self.toggle_fullscreen) - def escape(self, event): + def escape(self, event=None): self.withdraw() - sys.exit() + sys.exit(0) + + def toggle_fullscreen(self, event=None): + self.attributes('-fullscreen', not self.attributes('-fullscreen')) class ImageDisplay(Window): def __init__(self, filename, master=None): Window.__init__(self, master) - self.title('xv') + self.title(f'xv - {os.path.basename(filename)}') self.configure(background='grey') - try: - image = PIL.Image.open(filename) - except: - print('ERROR: IOError - Could not open %s' % (filename)) - else: - width, height = image.size - scale = 1.0 - if width > 1024: - scale = 1024 / float(width) - - scaled_width = int((scale * float(width))) - scaled_height = int((scale * float(height))) - # Used to use PIL.Image.ANTIALIAS. But that was dropped in - # Pillow 10.0.0. It was an alias to LANCZOS anyway. + try: + image = Image.open(filename) + except (UnidentifiedImageError, FileNotFoundError, PermissionError) as e: + print(f'ERROR: Could not open {filename}: {e}') + sys.exit(1) + except Exception as e: # fallback for anything else + print(f'ERROR: Unexpected error opening {filename}: {e}') + sys.exit(1) + + width, height = image.size + MAX_DIM = 1024 + + if width > MAX_DIM or height > MAX_DIM: + scale = MAX_DIM / float(max(width, height)) + scaled_width = int(width * scale) + scaled_height = int(height * scale) + + # Compatibility shim for Pillow >= 10 (ANTIALIAS -> LANCZOS) try: - RESAMPLE_LANCZOS = PIL.Image.Resampling.LANCZOS + resample = Image.Resampling.LANCZOS except AttributeError: - # Pillow < 9.1 or so - RESAMPLE_LANCZOS = PIL.Image.LANCZOS - # or PIL.Image.ANTIALIAS if you really need to support ancient versions + resample = Image.LANCZOS # older Pillow + + image = image.resize((scaled_width, scaled_height), resample) + else: + scaled_width, scaled_height = width, height - image = image.resize((scaled_width, scaled_height), RESAMPLE_LANCZOS) + # Keep a strong reference to the PhotoImage instance. + # (Otherwise the label's image would disappear + # when the PhotoImage assigned to it goes out + # of scope.) + self._photo_image = ImageTk.PhotoImage(image) - # Keep a reference to the PhotoImage instance. - # (Otherwise the label's image would disappear - # when the PhotoImage assigned to it goes out - # of scope.) - self._photo_image = PIL.ImageTk.PhotoImage(image) + label = tkinter.Label(self, image=self._photo_image, bg='grey') + label.pack(side='bottom', fill='both', expand='yes') - label = tkinter.Label(self, image=self._photo_image) - label.pack(side='bottom', fill='both', expand='yes') + # Size the window to the (possibly scaled) image + self.geometry(f"{scaled_width}x{scaled_height}") def main(args): @@ -88,6 +101,7 @@ def main(args): image_display.mainloop() return 0 + if __name__ == '__main__': parser = argparse.ArgumentParser( description="""DESCRIPTION From d43557508f5123da1917e4ec95ddcdfbf17dde80 Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 14:58:48 -0700 Subject: [PATCH 5/6] Some maintenance to move it toward conforming to style conventions. --- python/apps/pclock.py | 45 ++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/python/apps/pclock.py b/python/apps/pclock.py index 2c062c4..9ed7ae0 100644 --- a/python/apps/pclock.py +++ b/python/apps/pclock.py @@ -1,20 +1,21 @@ -""" pclock.py - Python clock +r""" +usage: pclock.py [-h] -Synopsis: - pclock.py +DESCRIPTION + A Python graphical clock. -Description: - A simple clock. +options: + -h, --help show this help message and exit -Notes: - -https://www.daniweb.com/programming/software-development/code/216785/tkinter-digital-clock-python -https://www.daniweb.com/programming/software-development/threads/70076/disabling-the-title-bar -https://stackoverflow.com/questions/29641616/drag-window-when-using-overrideredirect -https://stackoverflow.com/questions/39529600/remove-titlebar-without-overrideredirect-using-tkinter -https://stackoverflow.com/questions/28467285/how-do-i-bind-the-escape-key-to-close-this-window +NOTES + https://www.daniweb.com/programming/software-development/code/216785/tkinter-digital-clock-python + https://www.daniweb.com/programming/software-development/threads/70076/disabling-the-title-bar + https://stackoverflow.com/questions/29641616/drag-window-when-using-overrideredirect + https://stackoverflow.com/questions/39529600/remove-titlebar-without-overrideredirect-using-tkinter + https://stackoverflow.com/questions/28467285/how-do-i-bind-the-escape-key-to-close-this-window """ +import argparse import tkinter import tkinter.font import sys @@ -46,6 +47,7 @@ def __init__(self, master=None): self._offsety = 0 self.bind('', self.button1) self.bind('', self.b1motion) + self.bind('', self.escape) self.bind('', self.escape) def button1(self, event): @@ -59,7 +61,7 @@ def b1motion(self, event): def escape(self, event): self.withdraw() - sys.exit() + sys.exit(0) class Clock(FloatingBorderlessWindow): @@ -95,10 +97,23 @@ def tick(self): self._clock.after(1000, self.tick) -def main(): +def main(args): clock = Clock() clock.mainloop() + return 0 if __name__ == '__main__': - main() + parser = argparse.ArgumentParser( + description="""DESCRIPTION + A Python graphical clock.""", + epilog="""NOTES + https://www.daniweb.com/programming/software-development/code/216785/tkinter-digital-clock-python + https://www.daniweb.com/programming/software-development/threads/70076/disabling-the-title-bar + https://stackoverflow.com/questions/29641616/drag-window-when-using-overrideredirect + https://stackoverflow.com/questions/39529600/remove-titlebar-without-overrideredirect-using-tkinter + https://stackoverflow.com/questions/28467285/how-do-i-bind-the-escape-key-to-close-this-window""", + formatter_class=argparse.RawDescriptionHelpFormatter) + + args = parser.parse_args() + sys.exit(main(args)) From 5cbae7ad1c49c25617d1e552e9dea576b3b9797e Mon Sep 17 00:00:00 2001 From: Gerard Lanois Date: Sat, 20 Jun 2026 15:00:29 -0700 Subject: [PATCH 6/6] Initial checkin. --- python/apps/refresh_help.py | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 python/apps/refresh_help.py diff --git a/python/apps/refresh_help.py b/python/apps/refresh_help.py new file mode 100644 index 0000000..4864e9e --- /dev/null +++ b/python/apps/refresh_help.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +r""" +usage: refresh_help.py [-h] [-p] source_file + +Refresh module docstring from -h output + +positional arguments: + source_file Python script to update + +options: + -h, --help show this help message and exit + -p, --preview Preview changes without writing to file (pipe to + head/tail/etc as needed) +""" + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +def get_help_output(script_path: Path) -> str: + """Run the script with -h and return clean output.""" + try: + result = subprocess.run( + [sys.executable, str(script_path), "-h"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error running {script_path} with -h:\n{e.stderr or e}") + sys.exit(1) + except FileNotFoundError: + print(f"Script not found: {script_path}") + sys.exit(1) + + +def create_backup(file_path: Path): + """Create a .bak copy.""" + bak_path = file_path.with_suffix(file_path.suffix + ".bak") + shutil.copy2(file_path, bak_path) + print(f"💾 Backup created: {bak_path}") + + +def update_docstring(file_path: Path, help_text: str, preview: bool = False): + """Replace the module docstring.""" + content = file_path.read_text(encoding="utf-8") + + # Match leading comments + first triple-quoted string + docstring_pattern = re.compile( + r'^((#.*\n)*?)(r?"""[\s\S]*?"""|r?\'\'\'[\s\S]*?\'\'\')', + re.MULTILINE + ) + match = docstring_pattern.search(content) + + new_docstring = f'r"""\n{help_text}\n"""' + + if match: + prefix = match.group(1) # shebang + comments + updated = prefix + new_docstring + content[match.end():] + else: + # No docstring — insert after leading comments + lines = content.splitlines(keepends=True) + insert_pos = 0 + for i, line in enumerate(lines): + if not line.strip().startswith("#") and line.strip(): + insert_pos = i + break + lines.insert(insert_pos, new_docstring + "\n\n") + updated = "".join(lines) + + if preview: + print(updated) + else: + create_backup(file_path) + file_path.write_text(updated, encoding="utf-8") + print(f"✅ Successfully updated docstring in {file_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Refresh module docstring from -h output") + parser.add_argument("-p", "--preview", action="store_true", + help="Preview changes without writing to file (pipe to head/tail/etc as needed)") + parser.add_argument("source_file", type=Path, help="Python script to update") + args = parser.parse_args() + + if not args.source_file.exists(): + print(f"File not found: {args.source_file}") + sys.exit(1) + + help_output = get_help_output(args.source_file) + update_docstring(args.source_file, help_output, preview=args.preview) + + +if __name__ == "__main__": + main()