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:
Don Harper 2026-04-26 23:00:52 -05:00
parent 1a12e6d3c5
commit 51a1697c83
18 changed files with 866 additions and 166 deletions

24
Pens.csv~ Normal file
View file

@ -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,Dromgooles,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,Dromgooles,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

View file

@ -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

View file

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

View file

@ -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)

View file

@ -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()

View file

@ -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" },
]

View file

@ -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 <don@donharper.org>
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

View file

@ -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
src/pen_tracker.egg-info/top_level.txt
tests/test_engine.py

View file

@ -1,60 +1,25 @@
import os
from .engine import PenTracker
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):
# ... (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.")

View file

@ -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)

View file

@ -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)

View file

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

Binary file not shown.

37
tests/test_engine.py Normal file
View file

@ -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()