From 5635d941412bf1802047dbbeb33e1dd84780dd19 Mon Sep 17 00:00:00 2001 From: chrisdasht4 Date: Sat, 25 Apr 2026 13:48:49 -0700 Subject: [PATCH 1/5] Fix debug logging mode --- clippercard/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/clippercard/main.py b/clippercard/main.py index 13fe3cc..b3ac59e 100644 --- a/clippercard/main.py +++ b/clippercard/main.py @@ -145,5 +145,4 @@ def main(): if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) main() From 6023a3475c60dc402ee6439e2e23c2b4bab6329b Mon Sep 17 00:00:00 2001 From: chrisdasht4 Date: Sat, 25 Apr 2026 13:54:32 -0700 Subject: [PATCH 2/5] Add passList to parse_dashboard_cards --- clippercard/parser.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/clippercard/parser.py b/clippercard/parser.py index 05245dc..4f66cd1 100644 --- a/clippercard/parser.py +++ b/clippercard/parser.py @@ -24,6 +24,7 @@ import json import logging import re +from warnings import deprecated import bs4 @@ -158,6 +159,9 @@ def parse_login_form_fields(login_page_content): return fields +# Is this method still useful? Main client code now uses parse_dashboard_cards, +# while this method is 5 years out of date per git blame. +@deprecated("Only used in tests") def parse_cards(account_page_soup): """Parse the list of Clipper Cards registered to the profile""" card_info_divs = account_page_soup.find_all( @@ -354,6 +358,15 @@ def parse_dashboard_cards(dashboard_html_content): if balance is not None: products.append(CardProduct(name="BART", value=_cents_to_dollars(balance))) + passList = account.get("passList", []) + for pass_info in passList: + logger.debug("Parsing pass info: %s", pass_info.keys()) + pass_name = pass_info.get("passDescription", "Unknown Pass") + # NB: expirationDateTime is likely card validity, not pass expiration + # empirically it is ~100 years after pass activation + expiration = pass_info.get("endDateTime", "Unknown Expiration") + products.append(CardProduct(name=pass_name, value=f"Expires {expiration}")) + # Card features (currently empty, can extend later) features = [] From 66410dd4924b40616ed323db8df1dec58cedf08f Mon Sep 17 00:00:00 2001 From: chrisdasht4 Date: Sat, 25 Apr 2026 14:01:25 -0700 Subject: [PATCH 3/5] Hide address, name, etc. by default --- clippercard/main.py | 3 ++- clippercard/porcelain.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/clippercard/main.py b/clippercard/main.py index b3ac59e..98dd474 100644 --- a/clippercard/main.py +++ b/clippercard/main.py @@ -99,6 +99,7 @@ def _build_parser(): summary = subparsers.add_parser("summary", help="Show account summary") summary.add_argument("--debug", action="store_true", help="Enable debug logging") + summary.add_argument("--show-private", action="store_true", help="Show private information like card numbers (use with caution)") auth_group = summary.add_argument_group("authentication") auth_group.add_argument( @@ -139,7 +140,7 @@ def main(): if args.command == "summary": if session.reused_cookies: print(f"Reusing saved cookies from {session.cookie_jar_path}") - print(clippercard.porcelain.tabular_output(session.profile_info, session.cards)) + print(clippercard.porcelain.tabular_output(session.profile_info, session.cards, show_private=args.show_private)) except (clippercard.client.ClipperCardError, ClipperCardCommandError, FileNotFoundError) as e: sys.exit(str(e)) diff --git a/clippercard/porcelain.py b/clippercard/porcelain.py index 3a493ad..3a7d067 100644 --- a/clippercard/porcelain.py +++ b/clippercard/porcelain.py @@ -20,6 +20,7 @@ """ from io import StringIO +import re from rich import box from rich.console import Console @@ -40,8 +41,26 @@ def _render_table(table): console.print(table) return buffer.getvalue().rstrip() +def _redact_private_info(label, value): + match label.lower(): + case "name": + return ' '.join([part[:2] + "***" for part in value.split()]) + case "email": + local, domain = value.split("@") + return local[0] + "***@" + domain + case "mailing_address": + return "***" + case "phone": + re_match = re.match(r'(\+?\d{1,3})?([-.\s]?)[0-9-.\s]+(\d{2}-?\d{2})', value) + if re_match: + groups = re_match.groups() + redacted = f"{groups[0] + groups[1] if groups[0] else ''}***-***-{groups[2]}" + return redacted + case "serial_number": + return ("*" * (len(value) - 4)) + value[-4:] + return value -def tabular_output(user_profile, cards): +def tabular_output(user_profile, cards, show_private=False): """ Pretty prints a user profile and its associated cards and products. """ @@ -54,6 +73,8 @@ def tabular_output(user_profile, cards): for label, value in user_profile._asdict().items(): if value in (None, ""): continue + if not show_private: + value = _redact_private_info(label, value) profile_table.add_row(Text(str(label)), Text(str(value))) if profile_table.row_count: output_parts.append(_render_table(profile_table)) @@ -67,10 +88,13 @@ def tabular_output(user_profile, cards): card_table.add_column("Status", justify="left", no_wrap=True) card_table.add_column("Products", justify="left", no_wrap=True) for i, card in enumerate(cards, 1): + serial = card.serial_number + if not show_private: + serial = _redact_private_info("serial_number", serial) card_table.add_row( str(i), Text(str(card.nickname)), - Text(str(card.serial_number)), + Text(str(serial)), Text(str(card.type)), Text(str(card.status)), Text("\n".join(str(_) for _ in card.products + card.features)), From cf49f41534bb454585b56b1e6b128f0c4f9cbd63 Mon Sep 17 00:00:00 2001 From: chrisdasht4 Date: Sat, 25 Apr 2026 14:24:42 -0700 Subject: [PATCH 4/5] Unit test updates --- clippercard/parser.py | 5 +-- tests/data/dashboard.html | 58 +++++++++++++++++++++++++++++++++- tests/test_dashboard_parser.py | 10 ++++++ tests/test_porcelain.py | 36 ++++++++++++++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/clippercard/parser.py b/clippercard/parser.py index 4f66cd1..75c6f65 100644 --- a/clippercard/parser.py +++ b/clippercard/parser.py @@ -20,6 +20,7 @@ """ import collections +from datetime import datetime import itertools import json import logging @@ -364,8 +365,8 @@ def parse_dashboard_cards(dashboard_html_content): pass_name = pass_info.get("passDescription", "Unknown Pass") # NB: expirationDateTime is likely card validity, not pass expiration # empirically it is ~100 years after pass activation - expiration = pass_info.get("endDateTime", "Unknown Expiration") - products.append(CardProduct(name=pass_name, value=f"Expires {expiration}")) + expiration = datetime.fromisoformat(pass_info.get("endDateTime", "Unknown Expiration")).date() + products.append(CardProduct(name="Pass", value=f"{pass_name}\n - Expires {expiration}")) # Card features (currently empty, can extend later) features = [] diff --git a/tests/data/dashboard.html b/tests/data/dashboard.html index a6b721b..55f7edb 100644 --- a/tests/data/dashboard.html +++ b/tests/data/dashboard.html @@ -579,7 +579,63 @@