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.
This commit is contained in:
parent
1a12e6d3c5
commit
51a1697c83
18 changed files with 866 additions and 166 deletions
248
build/lib/pen_tracker/cli.py
Normal file
248
build/lib/pen_tracker/cli.py
Normal file
|
|
@ -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.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue