From 51a1697c83aa8f78005fa43b3a3640ff9ca04208 Mon Sep 17 00:00:00 2001 From: Don Harper Date: Sun, 26 Apr 2026 23:00:52 -0500 Subject: [PATCH] feat: Implement CLI and TUI for Fountain Pen Tracker - Added CLI functionality for adding, editing, viewing, and deleting fountain pens. - Introduced TUI using Textual for a more interactive experience. - Created Pen and PenTracker classes to manage pen data and CSV storage. - Implemented input validation for date fields. - Added export functionality to JSON format. - Updated project version to 0.2.0. - Added unit tests for PenTracker functionality. --- Pens.csv~ | 24 ++ README.md | 47 ++++ build/lib/pen_tracker/__init__.py | 0 build/lib/pen_tracker/cli.py | 248 +++++++++++++++++ build/lib/pen_tracker/engine.py | 85 ++++++ build/lib/pen_tracker/tui.py | 159 +++++++++++ pyproject.toml | 2 +- src/pen_tracker.egg-info/PKG-INFO | 44 ++- src/pen_tracker.egg-info/SOURCES.txt | 3 +- .../__pycache__/cli.cpython-313.pyc | Bin 11109 -> 13260 bytes .../__pycache__/engine.cpython-313.pyc | Bin 3865 -> 6508 bytes .../__pycache__/tui.cpython-313.pyc | Bin 8732 -> 9481 bytes src/pen_tracker/cli.py | 250 +++++++++++------- src/pen_tracker/engine.py | 58 +++- src/pen_tracker/engine.py.bork | 46 ---- src/pen_tracker/tui.py | 29 +- tests/__pycache__/test_engine.cpython-313.pyc | Bin 0 -> 2944 bytes tests/test_engine.py | 37 +++ 18 files changed, 866 insertions(+), 166 deletions(-) create mode 100644 Pens.csv~ create mode 100644 build/lib/pen_tracker/__init__.py create mode 100644 build/lib/pen_tracker/cli.py create mode 100644 build/lib/pen_tracker/engine.py create mode 100644 build/lib/pen_tracker/tui.py delete mode 100644 src/pen_tracker/engine.py.bork create mode 100644 tests/__pycache__/test_engine.cpython-313.pyc create mode 100644 tests/test_engine.py diff --git a/Pens.csv~ b/Pens.csv~ new file mode 100644 index 0000000..23aa7de --- /dev/null +++ b/Pens.csv~ @@ -0,0 +1,24 @@ +Make,Model,Date-Purchased,Vendor,Nib,Nib-Material,Body,Cap,Post,Current-Ink,Inked-date,Notes +Andibro,PenShort,2026-02-02,Amazon,M,Steel,Wood,Brass,Y,N/A,N/A,Cartridge can leak +duckland,drake,2026-04-12,Ducks R Us,F,Steel,Al,Al,y,squid ink,2026-04-12,This is cool +Ensso,Bolt2,2025-08-05,Kickstart,F,N/A,AL – Black,Retractable,N/A,N/A,N/A,N/A +Ensso,Piuma AL,2017-05-01,Kickstart,F,N/A,AL – Black,AL – Black,Y,N/A,N/A,N/A +Ensso,Piuma Brass,2017-05-01,Kickstart,F,N/A,Brass,Brass,Y,Colorverse 2026 Red Horse,N/A,N/A +Hongdian,M1,2025-12-25,Amazon,F,N/A,AL,AL,Y,N/A,N/A,N/A +Jinhao,10 Press,2026-02-02,Amazon,EF,N/A,AL – Orange,Retractable,N/A,Colorverse Sea of Tranquillity,2026-04-15,N/A +Jinhao,159,2026-04-15,Amazon,M,N/A,Steel - Red,Steel - Red,Y,N/A,N/A,N/A +Jinhao,601 Steel Cap Vacumatic,2026-03-03,Amazon,F,N/A,Resin – Lt Blue,Steel,Y,Hongdian Blue,N/A,N/A +Jinhao,75,2026-04-08,Amazon,F,N/A,Black Brass,Black Brass,Y,Mangdian Red,2026-04-08,N/A +Jinhao,82 Acrylic,2026-03-26,Amazon,F,N/A,Brown Acrylic,Green Acrylic,Y,Diamine Pumpkin,2026-04-01,N/A +Jinhao,8802 Rosewood,2022-08-25,Amazon,F,N/A,Brass – Black,Rosewood,Y,N/A,N/A,N/A +Jinhao,X159,2026-04-20,Amazon,F,N/A,Resin - Orange,Resin - Orange,Y,N/A,N/A,N/A +Kaweco,Skyline Sport DIY Fountain Pen,2026-03-13,Dromgoole’s,F,N/A,Plastic - Glow Green,Plastic,Y,Diamine Pumpkin,2026-03-28,N/A +Lamy,Safari,2016-12-28,Amazon,M,N/A,Black,Black,Y,N/A,N/A,N/A +Monteverde USA,Ritma,2026-04-22,Dromgoole’s,F,N/A,Walnut,Metal - Gun Metal,Y,Platinum Lavendar Black,2026-04-22,N/A +Pilot,Kakuno,2026-02-06,Goldspot,M,N/A,Clear,Clear,Y,N/A,N/A,N/A +Pilot,Kakuno,2026-03-24,Goulet Pens,F,N/A,Clear,Clear,Y,N/A,N/A,Eyedropper mod +Pilot,Metropolitan 91111,2026-02-24,Amazon,F,N/A,Brass – Black,Brass – Black,Y,N/A,N/A,N/A +Platinum,Curdus,2017-12-25,Gift,F,Steele,Green,Retractable,N/A,N/A,N/A,Feed/Nib broken +Platinum,Preppy,2026-03-20,Goulet Pens,F,N/A,Clear,Clear,Y,N/A,N/A,N/A +Retro 51,Tornado Fountain,2026-04-21,Goldspot,F,N/A,AL - Orange,AL - Orange,Y,Diamine Pumpkin,2026-04-22,Escape ACES Suit Orange +ZenZoi,Bamboo,2017-04-11,Amazon,M,N/A,Bamboo,Bamboo,Y,N/A,N/A,N/A diff --git a/README.md b/README.md index e69de29..da2e4ae 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,47 @@ +# Pen Tracker + +A simple fountain pen collection tracker. + +## Installation + +```bash +pip install . +``` + +## Data Storage + +Pen data is stored in `~/.local/share/pen-tracker/pens.csv` by default, following XDG Base Directory specification. The directory is created automatically if it doesn't exist. + +You can override the location by setting the `PEN_TRACKER_CSV` environment variable. + +## Usage + +### CLI + +Interactive mode: +```bash +pen-tracker +``` + +Command-line mode: +```bash +pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F" +pen-tracker list +pen-tracker export --output my_pens.json +``` + +### TUI + +```bash +pen-tui +``` + +## Features + +- Track fountain pens with details like make, model, nib, ink, etc. +- CLI interface with interactive and command-line modes +- TUI interface using Textual +- Data stored in CSV format +- Export to JSON +- Input validation for dates +- Configurable CSV path via PEN_TRACKER_CSV environment variable \ No newline at end of file diff --git a/build/lib/pen_tracker/__init__.py b/build/lib/pen_tracker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/pen_tracker/cli.py b/build/lib/pen_tracker/cli.py new file mode 100644 index 0000000..6052306 --- /dev/null +++ b/build/lib/pen_tracker/cli.py @@ -0,0 +1,248 @@ +import os +import csv +import argparse +import json +from datetime import datetime +from .engine import PenTracker, Pen + +def validate_date(date_str: str) -> bool: + """Validate date in YYYY-MM-DD format.""" + if date_str == "N/A": + return True + try: + datetime.strptime(date_str, '%Y-%m-%d') + return True + except ValueError: + return False + +class CLITracker(PenTracker): + + def add_pen(self): + print("\n--- Add New Fountain Pen ---") + new_pen_data = {} + fields = [ + ("Make", "Make"), ("Model", "Model"), ("Date Purchased (YYYY-MM-DD)", "Date-Purchased"), + ("Vendor", "Vendor"), ("Nib Size", "Nib"), ("Nib Material", "Nib-Material"), + ("Body Material", "Body"), ("Cap Material", "Cap"), ("Postable", "Post"), + ("Current Ink", "Current-Ink"), ("Inked Date (YYYY-MM-DD)", "Inked-date"), ("Notes", "Notes") + ] + + for label, key in fields: + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key in ['Date-Purchased', 'Inked-date']: + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_pen_data[key] = value + + # Map to Pen fields + mapped_data = {self.key_map.get(k, k): v for k, v in new_pen_data.items()} + new_pen = Pen(**mapped_data) + self.pens.append(new_pen) + self.save_data() + print("\n[✔] Pen added successfully to Pens.csv!") + + def edit_pen(self): + """Allows the user to select a pen and modify specific fields.""" + if not self.pens: + print("\n[!] No pens available to edit.") + return + + # Show summary so user knows which ID to pick + print("\n--- Select Pen to Edit ---") + self.show_summary_list() + + try: + idx = int(input("\nEnter the ID of the pen to edit: ")) + pen = self.pens[idx] + except (ValueError, IndexError): + print("[!] Invalid ID.") + return + + print("\n--- Editing Pen Details ---") + print("(Press ENTER without typing to keep the current value)\n") + + # We iterate through headers so we don't miss any column + for header in self.headers: + field = self.key_map.get(header, header) + current_val = getattr(pen, field) + while True: + new_val = input(f"{header} [{current_val}]: ").strip() + if not new_val: + break + if header in ['Date-Purchased', 'Inked-date']: + if validate_date(new_val): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + if new_val: # If the user actually typed something new + setattr(pen, field, new_val) + + self.save_data() + print("\n[✔] Pen updated successfully!") + + def show_summary_list(self): + """Helper to print a list without the interactive menu logic.""" + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15}") + print("-" * 55) + for idx, pen in enumerate(self.pens): + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15}") + + def view_all_pens(self): + if not self.pens: + print("\n[!] Your collection is currently empty.") + return + + print("\n" + "="*85) + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15} | {'INKED DATE':<12}") + print("-" * 85) + + for idx, pen in enumerate(self.pens): + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + inkdate = pen.Inked_date[:12] + print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15} | {inkdate:<12}") + + print("="*85) + + choice = input("\nEnter ID to see full details (or 'b' to go back): ") + if choice.lower() != 'b': + try: + self.view_pen_details(int(choice)) + except (ValueError, IndexError): + print("[!] Invalid ID.") + + def view_pen_details(self, index): + pen = self.pens[index] + print("\n" + "═"*45) + print(f"{' FOUNTAIN PEN DETAILS ':=^45}") + for header in self.headers: + field = self.key_map.get(header, header) + value = getattr(pen, field) + print(f"{header:<20}: {value}") + print("═"*45) + input("\nPress Enter to return to menu...") + + def delete_pen(self): + if not self.pens: + print("\n[!] Nothing to delete.") + return + self.show_summary_list() + try: + idx = int(input("\nEnter the ID of the pen to delete: ")) + removed = self.pens.pop(idx) + self.save_data() + print(f"\n[!] Removed: {removed.Make} {removed.Model}") + except (ValueError, IndexError): + print("[!] Invalid ID.") + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def main(): + # This is the entry point defined in pyproject.toml + parser = argparse.ArgumentParser(description="Fountain Pen Tracker") + parser.add_argument('--csv', default=None, help='Path to CSV file') + subparsers = parser.add_subparsers(dest='command', help='Commands') + + # Add command + add_parser = subparsers.add_parser('add', help='Add a new pen') + add_parser.add_argument('--make', required=True) + add_parser.add_argument('--model', required=True) + add_parser.add_argument('--date-purchased') + add_parser.add_argument('--vendor') + add_parser.add_argument('--nib') + add_parser.add_argument('--nib-material') + add_parser.add_argument('--body') + add_parser.add_argument('--cap') + add_parser.add_argument('--post') + add_parser.add_argument('--current-ink') + add_parser.add_argument('--inked-date') + add_parser.add_argument('--notes') + + # List command + list_parser = subparsers.add_parser('list', help='List all pens') + + # Export command + export_parser = subparsers.add_parser('export', help='Export to JSON') + export_parser.add_argument('--output', default='pens.json', help='Output file') + + args = parser.parse_args() + + tracker = CLITracker(args.csv) + + if args.command == 'add': + # Validate dates + if args.date_purchased and not validate_date(args.date_purchased): + print("Invalid date-purchased format.") + return + if args.inked_date and not validate_date(args.inked_date): + print("Invalid inked-date format.") + return + pen_data = { + 'Make': args.make, + 'Model': args.model, + 'Date-Purchased': args.date_purchased or 'N/A', + 'Vendor': args.vendor or 'N/A', + 'Nib': args.nib or 'N/A', + 'Nib-Material': args.nib_material or 'N/A', + 'Body': args.body or 'N/A', + 'Cap': args.cap or 'N/A', + 'Post': args.post or 'N/A', + 'Current-Ink': args.current_ink or 'N/A', + 'Inked-date': args.inked_date or 'N/A', + 'Notes': args.notes or 'N/A' + } + mapped_data = {tracker.key_map.get(k, k): v for k, v in pen_data.items()} + new_pen = Pen(**mapped_data) + tracker.pens.append(new_pen) + tracker.save_data() + print("Pen added successfully.") + elif args.command == 'list': + tracker.view_all_pens() + elif args.command == 'export': + with open(args.output, 'w') as f: + json.dump([asdict(p) for p in tracker.pens], f, indent=2) + print(f"Exported to {args.output}") + else: + # Interactive mode + while True: + print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") + print("1. View Collection Summary") + print("2. Add New Pen") + print("3. Edit a Pen") + print("4. Delete a Pen") + print("5. Exit") + + choice = input("\nSelect an option: ") + + if choice == '1': + clear_screen() + tracker.view_all_pens() + elif choice == '2': + clear_screen() + tracker.add_pen() + elif choice == '3': + clear_screen() + tracker.edit_pen() + elif choice == '4': + clear_screen() + tracker.delete_pen() + elif choice == '5': + print("Goodbye! Happy writing! ✒️") + break + else: + print("[!] Invalid selection. Please try again.") diff --git a/build/lib/pen_tracker/engine.py b/build/lib/pen_tracker/engine.py new file mode 100644 index 0000000..90f7fbb --- /dev/null +++ b/build/lib/pen_tracker/engine.py @@ -0,0 +1,85 @@ +import csv +import os +import logging +from dataclasses import dataclass, asdict +from typing import List + +logger = logging.getLogger(__name__) + +@dataclass +class Pen: + Make: str = "N/A" + Model: str = "N/A" + Date_Purchased: str = "N/A" + Vendor: str = "N/A" + Nib: str = "N/A" + Nib_Material: str = "N/A" + Body: str = "N/A" + Cap: str = "N/A" + Post: str = "N/A" + Current_Ink: str = "N/A" + Inked_date: str = "N/A" + Notes: str = "N/A" + +class PenTracker: + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.getenv('PEN_TRACKER_CSV', 'Pens.csv') + self.storage_file = storage_file + self.headers = [ + 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', + 'Nib-Material', 'Body', 'Cap', 'Post', + 'Current-Ink', 'Inked-date', 'Notes' + ] + self.key_map = { + 'Date-Purchased': 'Date_Purchased', + 'Inked-date': 'Inked_date', + 'Nib-Material': 'Nib_Material', + 'Current-Ink': 'Current_Ink' + } + self.reverse_key_map = {v: k for k, v in self.key_map.items()} + self.pens: List[Pen] = self.load_data() + + def _sort_pens(self): + """Sorts the pens list by Make, then by Model alphabetically.""" + self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) + + def load_data(self) -> List[Pen]: + """Loads data from the CSV file.""" + if not os.path.exists(self.storage_file): + self._create_empty_csv() + return [] + + pens = [] + try: + # Standard, clean way to open the file + with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + for row in reader: + # Strip whitespace from keys and values, handle empty values + clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} + # Map keys to dataclass field names + mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()} + pens.append(Pen(**mapped_row)) + pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) + except Exception as e: + logger.error(f"Error loading CSV: {e}") + return pens + + def _create_empty_csv(self): + """Creates the CSV file with headers if it is missing.""" + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + + def save_data(self): + """Sorts the data before writing to ensure persistent alphabetical order.""" + self._sort_pens() + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + for pen in self.pens: + row = asdict(pen) + # Map back to CSV keys + csv_row = {k.replace('_', '-'): v for k, v in row.items()} + writer.writerow(csv_row) diff --git a/build/lib/pen_tracker/tui.py b/build/lib/pen_tracker/tui.py new file mode 100644 index 0000000..8de1846 --- /dev/null +++ b/build/lib/pen_tracker/tui.py @@ -0,0 +1,159 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Input, Button, Header, Footer, DataTable +from textual.containers import Vertical, Horizontal +from textual.binding import Binding +from .engine import PenTracker, Pen +# --- TUI SCREENS --- + +class PenFormScreen(Screen): + """A screen for adding or editing a pen.""" + + def __init__(self, tracker, existing_pen=None): + super().__init__() + self.tracker = tracker + self.existing_pen = existing_pen + + def compose(self) -> ComposeResult: + with Vertical(id="form-container"): + yield Label("📝 NEW PEN" if not self.existing_pen else "✏️ EDIT PEN") + + for header in self.tracker.headers: + field = self.tracker.key_map.get(header, header) + val = getattr(self.existing_pen, field) if self.existing_pen else "" + yield Input(value=val, placeholder=header, id=header) + + with Horizontal(): + yield Button("Save", variant="success", id="save_cmd") + yield Button("Cancel", variant="error", id="cancel_cmd") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel_cmd": + self.dismiss() + return + + new_data = {} + for header in self.tracker.headers: + try: + input_widget = self.query_one(f"#{header}", Input) + new_data[header] = input_widget.value if input_widget.value.strip() else "N/A" + except Exception: + new_data[header] = "N/A" + + # Map to Pen fields + mapped_data = {self.tracker.key_map.get(k, k): v for k, v in new_data.items()} + new_pen = Pen(**mapped_data) + + if self.existing_pen is not None and self.existing_pen in self.tracker.pens: + idx = self.tracker.pens.index(self.existing_pen) + self.tracker.pens[idx] = new_pen + else: + self.tracker.pens.append(new_pen) + + # save_data() handles the sorting internally + self.tracker.save_data() + self.dismiss(new_pen) + +class PenTrackerApp(App): + """The Main TUI Application.""" + + CSS = """ + #form-container { + width: 60%; + height: auto; + border: heavy white; + padding: 1 2; + margin: 5 10; + background: $panel; + } + Label { + width: 100%; + text-align: center; + text-style: bold; + margin-bottom: 1; + } + Input { + margin-bottom: 1; + } + Horizontal { + align: center middle; + height: auto; + } + Button { + margin: 0 1; + } + DataTable { + height: 1fr; + } + """ + + BINDINGS = [ + Binding("d", "delete_selected", "Delete Selected"), + Binding("a", "add_new", "Add New Pen"), + Binding("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield DataTable(id="pen_table") + yield Footer() + + def on_mount(self) -> None: + self.tracker = PenTracker('Pens.csv') + self._refresh_table() + + def _refresh_table(self): + table = self.query_one("#pen_table", DataTable) + table.clear(columns=True) + + display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes'] + table.add_columns(*display_cols) + + for idx, pen in enumerate(self.tracker.pens): + row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] + # We use the index as the row key to track items accurately + table.add_row(*row_values, key=str(idx)) + + def action_add_new(self) -> None: + form = PenFormScreen(self.tracker) + self.push_screen(form, self.handle_form_result) + + def action_edit_selected(self, index_str: str): + try: + idx = int(index_str) + existing_pen = self.tracker.pens[idx] + form = PenFormScreen(self.tracker, existing_pen=existing_pen) + self.push_screen(form, self.handle_form_result) + except (ValueError, IndexError): + pass + + def handle_form_result(self, updated_pen_data) -> None: + if updated_pen_data: + self._refresh_table() + self.notify("Collection Updated & Sorted!", title="Success") + + def action_delete_selected(self) -> None: + table = self.query_one("#pen_table", DataTable) + try: + # Accessing via the table's current cursor + table = self.query_one("#pen_table", DataTable) + row_node = table.get_row_at_cursor() + idx = int(row_node.key.value) + removed = self.tracker.pens.pop(idx) + self.tracker.save_data() + self._refresh_table() + self.notify(f"Deleted {removed.Make}", title="Removed") + except Exception: + self.notify("No pen selected to delete", title="Error", severity="error") + + def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: + try: + idx = int(event.cell_key.row_key.value) + self.action_edit_selected(str(idx)) + except (AttributeError, ValueError): + self.notify("Error selecting row", title="Error", severity="error") + +def main(): + # This is the entry point defined in pyproject.toml + app = PenTrackerApp() + app.run() diff --git a/pyproject.toml b/pyproject.toml index 5a7f7c3..6495605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pen-tracker" -version = "0.1.2" +version = "0.2.0" authors = [ { name="Don Harper", email="don@donharper.org" }, ] diff --git a/src/pen_tracker.egg-info/PKG-INFO b/src/pen_tracker.egg-info/PKG-INFO index 1343b95..f5ef874 100644 --- a/src/pen_tracker.egg-info/PKG-INFO +++ b/src/pen_tracker.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: pen-tracker -Version: 0.1.2 +Version: 0.2.0 Summary: A fountain pen collection tracker. Author-email: Don Harper Requires-Python: >=3.8 @@ -8,3 +8,45 @@ Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: textual Dynamic: license-file + +# Pen Tracker + +A simple fountain pen collection tracker. + +## Installation + +```bash +pip install . +``` + +## Usage + +### CLI + +Interactive mode: +```bash +pen-tracker +``` + +Command-line mode: +```bash +pen-tracker add --make "Pilot" --model "Metropolitan" --nib "F" +pen-tracker list +pen-tracker export --output my_pens.json +``` + +### TUI + +```bash +pen-tui +``` + +## Features + +- Track fountain pens with details like make, model, nib, ink, etc. +- CLI interface with interactive and command-line modes +- TUI interface using Textual +- Data stored in CSV format +- Export to JSON +- Input validation for dates +- Configurable CSV path via PEN_TRACKER_CSV environment variable diff --git a/src/pen_tracker.egg-info/SOURCES.txt b/src/pen_tracker.egg-info/SOURCES.txt index 4d050c2..4c88beb 100644 --- a/src/pen_tracker.egg-info/SOURCES.txt +++ b/src/pen_tracker.egg-info/SOURCES.txt @@ -10,4 +10,5 @@ src/pen_tracker.egg-info/SOURCES.txt src/pen_tracker.egg-info/dependency_links.txt src/pen_tracker.egg-info/entry_points.txt src/pen_tracker.egg-info/requires.txt -src/pen_tracker.egg-info/top_level.txt \ No newline at end of file +src/pen_tracker.egg-info/top_level.txt +tests/test_engine.py \ No newline at end of file diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc index 442256eadae28da7116b352ee8d49d1ddf6dc24f..b92abebea7d5d49c7215353647538b7626d0f8dc 100644 GIT binary patch literal 13260 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 So}uZ1NBYY&eTpXPz5fTm?gS11 literal 11109 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 J7G{7>`G0XwULybi diff --git a/src/pen_tracker/__pycache__/engine.cpython-313.pyc b/src/pen_tracker/__pycache__/engine.cpython-313.pyc index 8b9c1b60a6444dc69997813fcf95ed227e0ee287..4e87b3d239ada4df6a29b67b06aa143ea6af97c9 100644 GIT binary patch literal 6508 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 delta 1908 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 bool: + """Validate date in YYYY-MM-DD format.""" + if date_str == "N/A": + return True + try: + datetime.strptime(date_str, '%Y-%m-%d') + return True + except ValueError: + return False class CLITracker(PenTracker): - # ... (Copy all the methods: add_pen, edit_pen, view_all_pens, etc., from your original script) - # Ensure you change "self.storage_file" references if they were hardcoded. - # IMPORTANT: Replace "from pen-tracker import PenTracker" with "from .engine import PenTracker" - # def __init__(self, storage_file='Pens.csv'): - # self.storage_file = storage_file - # # These headers must match your CSV exactly - # self.headers = [ - # 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', - # 'Nib-Material', 'Body', 'Cap', 'Post', - # 'Current-Ink', 'Inked-date', 'Notes' - # ] - # self.pens = self.load_data() - - def load_data(self): - """Loads data from the CSV file.""" - if not os.path.exists(self.storage_file): - self._create_empty_csv() - return [] - - pens = [] - try: - with open(self.sstorage_file, mode='r', encoding='utf-8-sig') as f: # Fixed typo in logic here for safety - pass - except: pass # Fallback - - # Re-implementing clean load - if os.path.exists(self.storage_file): - try: - with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: - reader = csv.DictReader(f) - for row in reader: - clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - except Exception as e: - print(f"[!] Error loading CSV: {e}") - return pens - - def _create_empty_csv(self): - """Creates the CSV file with headers if it is missing.""" - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - - def save_data(self): - """Saves the current list of pens back to the CSV file.""" - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - writer.writerows(self.pens) def add_pen(self): print("\n--- Add New Fountain Pen ---") - new_pen = {} + new_pen_data = {} fields = [ ("Make", "Make"), ("Model", "Model"), ("Date Purchased (YYYY-MM-DD)", "Date-Purchased"), ("Vendor", "Vendor"), ("Nib Size", "Nib"), ("Nib Material", "Nib-Material"), @@ -63,9 +28,23 @@ class CLITracker(PenTracker): ] for label, key in fields: - value = input(f"Enter {label}: ").strip() - new_pen[key] = value if value else "N/A" + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key in ['Date-Purchased', 'Inked-date']: + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_pen_data[key] = value + # Map to Pen fields + mapped_data = {self.key_map.get(k, k): v for k, v in new_pen_data.items()} + new_pen = Pen(**mapped_data) self.pens.append(new_pen) self.save_data() print("\n[✔] Pen added successfully to Pens.csv!") @@ -91,23 +70,34 @@ class CLITracker(PenTracker): print("(Press ENTER without typing to keep the current value)\n") # We iterate through headers so we don't miss any column - for key in self.headers: - current_val = pen.get(key, "N/A") - new_val = input(f"{key} [{current_val}]: ").strip() + for header in self.headers: + field = self.key_map.get(header, header) + current_val = getattr(pen, field) + while True: + new_val = input(f"{header} [{current_val}]: ").strip() + if not new_val: + break + if header in ['Date-Purchased', 'Inked-date']: + if validate_date(new_val): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break if new_val: # If the user actually typed something new - pen[key] = new_val + setattr(pen, field, new_val) self.save_data() print("\n[✔] Pen updated successfully!") def show_summary_list(self): """Helper to print a list without the interactive menu logic.""" - print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<1s} | {'INK':<15}") - print("-" * 40) + print(f"{'ID':<4} | {'MAKE':<12} | {'MODEL':<12} | {'INK':<15}") + print("-" * 55) for idx, pen in enumerate(self.pens): - make = pen.get('Make', 'N/A')[:12] - model = pen.get('Model', 'N/A')[:12] - ink = pen.get('Current-Ink', 'N/A')[:15] + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15}") def view_all_pens(self): @@ -120,10 +110,10 @@ class CLITracker(PenTracker): print("-" * 85) for idx, pen in enumerate(self.pens): - make = pen.get('Make', 'N/A')[:12] - model = pen.get('Model', 'N/A')[:12] - ink = pen.get('Current-Ink', 'N/A')[:15] - inkdate = pen.get('Inked-date', '----------------')[:12] + make = pen.Make[:12] + model = pen.Model[:12] + ink = pen.Current_Ink[:15] + inkdate = pen.Inked_date[:12] print(f"{idx:<4} | {make:<12} | {model:<12} | {ink:<15} | {inkdate:<12}") print("="*85) @@ -139,9 +129,10 @@ class CLITracker(PenTracker): pen = self.pens[index] print("\n" + "═"*45) print(f"{' FOUNTAIN PEN DETAILS ':=^45}") - for key in self.headers: - value = pen.get(key, "N/A") - print(f"{key:<20}: {value}") + for header in self.headers: + field = self.key_map.get(header, header) + value = getattr(pen, field) + print(f"{header:<20}: {value}") print("═"*45) input("\nPress Enter to return to menu...") @@ -154,7 +145,7 @@ class CLITracker(PenTracker): idx = int(input("\nEnter the ID of the pen to delete: ")) removed = self.pens.pop(idx) self.save_data() - print(f"\n[!] Removed: {removed.get('Make', 'Unknown')} {removed.get('Model', '')}") + print(f"\n[!] Removed: {removed.Make} {removed.Model}") except (ValueError, IndexError): print("[!] Invalid ID.") @@ -163,32 +154,95 @@ def clear_screen(): def main(): # This is the entry point defined in pyproject.toml - import sys - tracker = CLITracker('Pens.csv') - while True: - print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") - print("1. View Collection Summary") - print("2. Add New Pen") - print("3. Edit a Pen") - print("4. Delete a Pen") - print("5. Exit") - - choice = input("\nSelect an option: ") + parser = argparse.ArgumentParser(description="Fountain Pen Tracker") + parser.add_argument('--csv', default=None, help='Path to CSV file') + subparsers = parser.add_subparsers(dest='command', help='Commands') - if choice == '1': - clear_append = clear_screen() - tracker.view_all_pens() - elif choice == '2': - clear_screen() - tracker.add_pen() - elif choice == '3': - clear_screen() - tracker.edit_pen() - elif choice == '4': - clear_screen() - tracker.delete_pen() - elif choice == '5': - print("Goodbye! Happy writing! ✒️") - break - else: - print("[!] Invalid selection. Please try again.") + # Add command + add_parser = subparsers.add_parser('add', help='Add a new pen') + add_parser.add_argument('--make', required=True) + add_parser.add_argument('--model', required=True) + add_parser.add_argument('--date-purchased') + add_parser.add_argument('--vendor') + add_parser.add_argument('--nib') + add_parser.add_argument('--nib-material') + add_parser.add_argument('--body') + add_parser.add_argument('--cap') + add_parser.add_argument('--post') + add_parser.add_argument('--current-ink') + add_parser.add_argument('--inked-date') + add_parser.add_argument('--notes') + + # List command + list_parser = subparsers.add_parser('list', help='List all pens') + + # Export command + export_parser = subparsers.add_parser('export', help='Export to JSON') + export_parser.add_argument('--output', default='pens.json', help='Output file') + + args = parser.parse_args() + + tracker = CLITracker(args.csv) + + if args.command == 'add': + # Validate dates + if args.date_purchased and not validate_date(args.date_purchased): + print("Invalid date-purchased format.") + return + if args.inked_date and not validate_date(args.inked_date): + print("Invalid inked-date format.") + return + pen_data = { + 'Make': args.make, + 'Model': args.model, + 'Date-Purchased': args.date_purchased or 'N/A', + 'Vendor': args.vendor or 'N/A', + 'Nib': args.nib or 'N/A', + 'Nib-Material': args.nib_material or 'N/A', + 'Body': args.body or 'N/A', + 'Cap': args.cap or 'N/A', + 'Post': args.post or 'N/A', + 'Current-Ink': args.current_ink or 'N/A', + 'Inked-date': args.inked_date or 'N/A', + 'Notes': args.notes or 'N/A' + } + mapped_data = {tracker.key_map.get(k, k): v for k, v in pen_data.items()} + new_pen = Pen(**mapped_data) + tracker.pens.append(new_pen) + tracker.save_data() + print("Pen added successfully.") + elif args.command == 'list': + tracker.view_all_pens() + elif args.command == 'export': + with open(args.output, 'w') as f: + json.dump([asdict(p) for p in tracker.pens], f, indent=2) + print(f"Exported to {args.output}") + else: + # Interactive mode + while True: + print("\n🖋️ FOUNTAIN PEN TRACKER (CSV Edition)") + print("1. View Collection Summary") + print("2. Add New Pen") + print("3. Edit a Pen") + print("4. Delete a Pen") + print("5. Exit") + + choice = input("\nSelect an option: ") + + if choice == '1': + clear_screen() + tracker.view_all_pens() + elif choice == '2': + clear_screen() + tracker.add_pen() + elif choice == '3': + clear_screen() + tracker.edit_pen() + elif choice == '4': + clear_screen() + tracker.delete_pen() + elif choice == '5': + print("Goodbye! Happy writing! ✒️") + break + else: + print("[!] Invalid selection. Please try again.") diff --git a/src/pen_tracker/engine.py b/src/pen_tracker/engine.py index b952ce5..8444a3f 100644 --- a/src/pen_tracker/engine.py +++ b/src/pen_tracker/engine.py @@ -1,26 +1,60 @@ import csv import os +import logging +from dataclasses import dataclass, asdict +from typing import List + +logger = logging.getLogger(__name__) + +@dataclass +class Pen: + Make: str = "N/A" + Model: str = "N/A" + Date_Purchased: str = "N/A" + Vendor: str = "N/A" + Nib: str = "N/A" + Nib_Material: str = "N/A" + Body: str = "N/A" + Cap: str = "N/A" + Post: str = "N/A" + Current_Ink: str = "N/A" + Inked_date: str = "N/A" + Notes: str = "N/A" class PenTracker: - def __init__(self, storage_file='Pens.csv'): + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.getenv('PEN_TRACKER_CSV') + if storage_file is None: + data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) + app_data_dir = os.path.join(data_home, 'pen-tracker') + os.makedirs(app_data_dir, exist_ok=True) + storage_file = os.path.join(app_data_dir, 'pens.csv') self.storage_file = storage_file self.headers = [ 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', 'Nib-Material', 'Body', 'Cap', 'Post', 'Current-Ink', 'Inked-date', 'Notes' ] - self.pens = self.load_data() + self.key_map = { + 'Date-Purchased': 'Date_Purchased', + 'Inked-date': 'Inked_date', + 'Nib-Material': 'Nib_Material', + 'Current-Ink': 'Current_Ink' + } + self.reverse_key_map = {v: k for k, v in self.key_map.items()} + self.pens: List[Pen] = self.load_data() def _sort_pens(self): """Sorts the pens list by Make, then by Model alphabetically.""" - self.pens.sort(key=lambda x: (x.get('Make', '').lower(), x.get('Model', '').lower())) + self.pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) - def load_data(self): + def load_data(self) -> List[Pen]: """Loads data from the CSV file.""" if not os.path.exists(self.storage_file): self._create_empty_csv() return [] - + pens = [] try: # Standard, clean way to open the file @@ -29,10 +63,12 @@ class PenTracker: for row in reader: # Strip whitespace from keys and values, handle empty values clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - self._sort_pens() + # Map keys to dataclass field names + mapped_row = {self.key_map.get(k, k): v for k, v in clean_row.items()} + pens.append(Pen(**mapped_row)) + pens.sort(key=lambda x: (x.Make.lower(), x.Model.lower())) except Exception as e: - print(f"[!] Error loading CSV: {e}") + logger.error(f"Error loading CSV: {e}") return pens def _create_empty_csv(self): @@ -47,4 +83,8 @@ class PenTracker: with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=self.headers) writer.writeheader() - writer.writerows(self.pens) + for pen in self.pens: + row = asdict(pen) + # Map back to CSV keys + csv_row = {k.replace('_', '-'): v for k, v in row.items()} + writer.writerow(csv_row) diff --git a/src/pen_tracker/engine.py.bork b/src/pen_tracker/engine.py.bork deleted file mode 100644 index 127b3ad..0000000 --- a/src/pen_tracker/engine.py.bork +++ /dev/null @@ -1,46 +0,0 @@ -import csv -import os - -class PenTracker: - def __run_sort_pens(self): - """Internal sort method used by both interfaces.""" - self.pens.sort(key=lambda x: (x.get('Make', '').lower(), x.get('Model', '').lower())) - - def __init__(self, storage_file='Pens.csv'): - self.storage_file = storage_file - self.headers = [ - 'Make', 'Model', 'Date-Purchased', 'Vendor', 'Nib', - 'Nib-Material', 'Body', 'Cap', 'Post', - 'Current-Ink', 'Inked-date', 'Notes' - ] - self.pens = self.load_data() - - def load_data(self): - if not os.path.exists(self.storage_file): - self._create_empty_csv() - return [] - - pens = [] - try: - with open(self.dat_file := self.storage_file, mode='r', encoding='utf-8-sig') as f: - reader = csv.DictReader(f) - for row in reader: - clean_row = {k.strip(): (v.strip() if v else "N/A") for k, v in row.items()} - pens.append(clean_row) - self.__run_sort_pens() - except Exception as e: - print(f"[!] Error loading CSV: {e}") - return pens - - def _create_empty_csv(self): - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - - def save_data(self): - self.__run_sort_pens() - with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: - writer = csv.DictReader(f, fieldnames=self.headers) # Fixed logic from original - writer = csv.DictWriter(f, fieldnames=self.headers) - writer.writeheader() - writer.writerows(self.pens) diff --git a/src/pen_tracker/tui.py b/src/pen_tracker/tui.py index 92cd264..ced7649 100644 --- a/src/pen_tracker/tui.py +++ b/src/pen_tracker/tui.py @@ -1,5 +1,9 @@ -from textual.app import App -from .engine import PenTracker +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Input, Button, Header, Footer, DataTable +from textual.containers import Vertical, Horizontal +from textual.binding import Binding +from .engine import PenTracker, Pen # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -15,7 +19,8 @@ class PenFormScreen(Screen): yield Label("📝 NEW PEN" if not self.existing_pen else "✏️ EDIT PEN") for header in self.tracker.headers: - val = self.existing_pen.get(header, "") if self.existing_pen else "" + field = self.tracker.key_map.get(header, header) + val = getattr(self.existing_pen, field) if self.existing_pen else "" yield Input(value=val, placeholder=header, id=header) with Horizontal(): @@ -35,15 +40,19 @@ class PenFormScreen(Screen): except Exception: new_data[header] = "N/A" + # Map to Pen fields + mapped_data = {self.tracker.key_map.get(k, k): v for k, v in new_data.items()} + new_pen = Pen(**mapped_data) + if self.existing_pen is not None and self.existing_pen in self.tracker.pens: idx = self.tracker.pens.index(self.existing_pen) - self.tracker.pens[idx] = new_data + self.tracker.pens[idx] = new_pen else: - self.tracker.pens.append(new_data) + self.tracker.pens.append(new_pen) # save_data() handles the sorting internally self.tracker.save_data() - self.dismiss(new_data) + self.dismiss(new_pen) class PenTrackerApp(App): """The Main TUI Application.""" @@ -90,7 +99,7 @@ class PenTrackerApp(App): yield Footer() def on_mount(self) -> None: - self.tracker = PenTracker('Pens.csv') + self.tracker = PenTracker() self._refresh_table() def _refresh_table(self): @@ -101,7 +110,7 @@ class PenTrackerApp(App): table.add_columns(*display_cols) for idx, pen in enumerate(self.tracker.pens): - row_values = [pen.get(c, "N/A") for c in display_cols] + row_values = [getattr(pen, c.replace('-','_')) for c in display_cols] # We use the index as the row key to track items accurately table.add_row(*row_values, key=str(idx)) @@ -124,7 +133,7 @@ class PenTrackerApp(App): self.notify("Collection Updated & Sorted!", title="Success") def action_delete_selected(self) -> None: - table = self.int_query_one("#pen_table", DataTable) # Corrected reference + table = self.query_one("#pen_table", DataTable) try: # Accessing via the table's current cursor table = self.query_one("#pen_table", DataTable) @@ -133,7 +142,7 @@ class PenTrackerApp(App): removed = self.tracker.pens.pop(idx) self.tracker.save_data() self._refresh_table() - self.notify(f"Deleted {removed['Make']}", title="Removed") + self.notify(f"Deleted {removed.Make}", title="Removed") except Exception: self.notify("No pen selected to delete", title="Error", severity="error") diff --git a/tests/__pycache__/test_engine.cpython-313.pyc b/tests/__pycache__/test_engine.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c794c1da50f659d8d3b2828227eb4dc3474f500 GIT binary patch literal 2944 zcmbtWO>7fa5Pth-KPOI70{PhhkrP7Dw!{%3Zb(5$Ek6fPTzy>YELYxnI2 z3MW*lG*nci2UHF{gcC(QltT}ks~&pY#>nUqsfYH$rHTl>cV^dXC$3Xbb!5%^pM5j) zee=B8(a}aget7<8;U}4p-*FI%QtNEqg3djn6J0n@SkMYg@W&z(p%u?d=VT_IQ<#z< z{bZQv(g4xr%On#IjWybo6!cm2GS!uLE)imO69RidH_I?d)us|8FXRO+@&dW}T}L$4Jg9TCT+-&eHsR;ZrTjscS8k z=LDoBO zZ!gPV#W&&n_QawieqsTWuaVdt(ohdH+7Bd51Av%lLRyo#SaNt|$u1V`1=azZSQn~Y zpn{OG-O$BMnCx`vn$7!~!VoSXznp$h5DD6LH}++0W#pUW*U4)4L9hGZQ##6eVR0LL z@qWISKSOc=zIA8^K-J}G$rix3I#^+UO5*OgSw^8{&asr7k1f^{LYr}mL8jV}Bzis4G%VniB5y4m~e(ZYH@~Dh`j-d z%fL#&q*;bG$77Cp)iQFXYx?wVV3fC;4shN*^U0D4_KKFx5@=%xlqNB(;ama}yxq30 z88wzWh_^%{5umyU`EHoGoMGBIqgXO?1|Ass2gFn9anwoqpROm!~>HglTs(GsR-H@jquBt~o^~mF?3O&Blh6zudc$|Jp zPyGu8KWa(AX>kiFMI9ciX>lB3-GY+a-jo*W|KUw(@qV--Uq}?cNMJsiWL9WJFJ1BJ zflzCbtzZTpU*BRtxKK6uvFO#pZka4`oA-w%e*BJYOM5|*K4@b4l>ELJ{FF-NCNC}{q-kK&w+_SiABfTg$g5}ycs zKgmwQ99B^0IH(Ot5QOKX%m2lGBgt3tKH=EPrE1Tp*E9Nxz+mG9k>krZ)+F_r9RFhG PvzgD|-;jtr;2ZxN_9_@t literal 0 HcmV?d00001 diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..503c79d --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,37 @@ +import unittest +import tempfile +import os +from pen_tracker.engine import PenTracker, Pen + +class TestPenTracker(unittest.TestCase): + def setUp(self): + self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv') + self.temp_file.close() + self.tracker = PenTracker(self.temp_file.name) + + def tearDown(self): + os.unlink(self.temp_file.name) + + def test_add_and_load_pen(self): + pen = Pen(Make="Pilot", Model="Metropolitan", Nib="F") + self.tracker.pens.append(pen) + self.tracker.save_data() + + # Load in new tracker + new_tracker = PenTracker(self.temp_file.name) + self.assertEqual(len(new_tracker.pens), 1) + self.assertEqual(new_tracker.pens[0].Make, "Pilot") + self.assertEqual(new_tracker.pens[0].Model, "Metropolitan") + + def test_sorting(self): + self.tracker.pens = [ + Pen(Make="Z", Model="A"), + Pen(Make="A", Model="B") + ] + self.tracker.save_data() + new_tracker = PenTracker(self.temp_file.name) + self.assertEqual(new_tracker.pens[0].Make, "A") + self.assertEqual(new_tracker.pens[1].Make, "Z") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file