pen-tracker/build/lib/pen_tracker/cli.py
Don Harper 51a1697c83 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.
2026-04-26 23:00:52 -05:00

248 lines
8.9 KiB
Python

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.")