Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions clippercard/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ 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(
Expand Down Expand Up @@ -139,11 +142,14 @@ 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))


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
14 changes: 14 additions & 0 deletions clippercard/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
"""

import collections
from datetime import datetime
import itertools
import json
import logging
import re
from warnings import deprecated

import bs4

Expand Down Expand Up @@ -158,6 +160,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(
Expand Down Expand Up @@ -354,6 +359,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 = 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 = []

Expand Down
30 changes: 28 additions & 2 deletions clippercard/porcelain.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

from io import StringIO
import re

from rich import box
from rich.console import Console
Expand All @@ -41,7 +42,27 @@ def _render_table(table):
return buffer.getvalue().rstrip()


def tabular_output(user_profile, cards):
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, show_private=False):
"""
Pretty prints a user profile and its associated cards and products.
"""
Expand All @@ -54,6 +75,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))
Expand All @@ -67,10 +90,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)),
Expand Down
58 changes: 57 additions & 1 deletion tests/data/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,63 @@ <h5 class="dropdown-header">
"reloadable": false,
"archived": false
},
"passList": [],
"passList": [{
"subscriptionId": "123456",
"fundingSourceSetId": "789012",
"passSKU": "CLFP-SKU-345-P67890-000000",
"passDescription": "VTA Standard Pass",
"createdDateTime": "2026-03-31T14:00:00.000-07:00",
"startDateTime": "2026-04-01T00:00:00.000-07:00",
"endDateTime": "2026-05-01T00:00:00.000-07:00",
"remainingDuration": 5000,
"durationType": "Months",
"expirationDateTime": "2126-03-01T00:00:00.000-08:00",
"deactivatedDateTime": null,
"deactivatedReason": null,
"passSerialNbr": "123456",
"fareProductTypeId": "789",
"supportsAutoload": false,
"isRefundable": null,
"totalRefundAmount": null,
"passCost": null,
"passPayments": null,
"firstUseDtm": null,
"passUseCount": 32,
"supportsTransfer": true,
"supportsAssign": true,
"remainingAssignCount": 4,
"maxAssignCount": 5,
"allowsRefund": null,
"isArchived": null,
"remainingFareProductTypeCount": null,
"travelTokenId": "123456",
"tokenPartial": "7890",
"loadDtm": null,
"duration": 1,
"autoloadEnabled": true,
"passLoadTxnDtm": null,
"autoloadId": null,
"accountId": null,
"fareInstrumentId": null,
"fareInstrumentInstance": null,
"zones": null,
"permittedUsers": [
{
"operatorId": 17,
"operatorName": "VTA"
}
],
"count": 1,
"autoloadEligiblePass": false,
"showActive": true,
"monthlyPass": true,
"autoloadSuspended": false,
"autoloadSuspendedReason": "Active",
"alFailedFsList": null,
"showAutoloadOptions": true,
"endDateTimeMinusDay": "2026-04-30T00:00:00.000-07:00",
"autoloadProduct": false
}],
"purseList": [{
"nickname": null,
"balance": 4175,
Expand Down
10 changes: 10 additions & 0 deletions tests/test_dashboard_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ def test_parse_card_without_bart(self, dashboard_html):
assert len(cash_products) == 1
assert cash_products[0].value == "$41.75"

def test_parse_with_pass_list(self, dashboard_html):
"""Card with passList should have products for each pass"""
result = parser.parse_dashboard_cards(dashboard_html)
# First card "Sample Card 1" has a passList with 1 pass
first_card = result[0]
pass_products = [p for p in first_card.products if p.name == "Pass"]
assert len(pass_products) == 1
assert "VTA Standard Pass" in pass_products[0].value
assert "Expires 2026-05-01" in pass_products[0].value

def test_parse_serial_numbers(self, dashboard_html):
"""Verify serial numbers are parsed"""
result = parser.parse_dashboard_cards(dashboard_html)
Expand Down
37 changes: 36 additions & 1 deletion tests/test_porcelain.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_tabular_output_renders_profile_and_cards_as_ascii_tables():
)
]

output = tabular_output(profile, cards)
output = tabular_output(profile, cards, show_private=True)

assert (
output
Expand All @@ -39,5 +39,40 @@ def test_tabular_output_renders_profile_and_cards_as_ascii_tables():
)


def test_tabular_output_renders_profile_and_cards_as_ascii_tables_without_private_info():
profile = namedtuple("Profile", "name email alt_phone")(
name="Golden Gate Hacker",
email="goldengate88@systemfu.com",
alt_phone="",
)
cards = [
SimpleNamespace(
nickname="Primary, card ending in 4134",
serial_number="2021234134",
type="ADULT",
status="Active",
products=["Cash Value: $195.00", "Current Passes: None"],
features=["Reload: $255 - Autoload"],
)
]

output = tabular_output(profile, cards, show_private=False)

assert (
output
== """+---------------------------+
| name | Go*** Ga*** Ha*** |
| email | g***@systemfu.com |
+---------------------------+
+------------------------------------------------------------------------------------------+
| # | Name | Serial | Type | Status | Products |
|---+------------------------------+------------+-------+--------+-------------------------|
| 1 | Primary, card ending in 4134 | ******4134 | ADULT | Active | Cash Value: $195.00 |
| | | | | | Current Passes: None |
| | | | | | Reload: $255 - Autoload |
+------------------------------------------------------------------------------------------+"""
)


def test_tabular_output_reports_missing_cards():
assert tabular_output(None, []) == "No cards registered"