diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2788d3c..853c134 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,11 @@ jobs: - name: SAST (bandit) — advisory run: uv run bandit -r src/ apps/ mcp/ -c pyproject.toml continue-on-error: true + + - name: Lint HTML (djlint check) + run: uv run djlint apps/ --lint + continue-on-error: ${{ env.ADVISORY == 'true' }} + + - name: Format check HTML (djlint format) — advisory + run: uv run djlint apps/ --check + continue-on-error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0a9a63..7bb543a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,12 @@ repos: - id: detect-secrets args: [--baseline, .secrets.baseline] + - repo: https://github.com/djlint/djLint + rev: v1.36.4 + hooks: + - id: djlint-jinja + args: [--lint] + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: diff --git a/apps/dashboard_web/templates/base.html b/apps/dashboard_web/templates/base.html index f2fefb6..798a133 100644 --- a/apps/dashboard_web/templates/base.html +++ b/apps/dashboard_web/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}PISCES · Dashboard{% endblock %} + {% block title %}PISCES · Dashboard{% endblock title %} -{% block extra_scripts %}{% endblock %} +{% block extra_scripts %}{% endblock extra_scripts %} diff --git a/apps/dashboard_web/templates/dashboard.html b/apps/dashboard_web/templates/dashboard.html index e95e34e..43b2fa4 100644 --- a/apps/dashboard_web/templates/dashboard.html +++ b/apps/dashboard_web/templates/dashboard.html @@ -48,7 +48,7 @@

Loading Threat Intel data…

-{% endblock %} +{% endblock content %} {% block extra_scripts %} -{% endblock %} +{% endblock extra_scripts %} diff --git a/apps/hub/templates/index.html b/apps/hub/templates/index.html index 475ad80..cebb391 100644 --- a/apps/hub/templates/index.html +++ b/apps/hub/templates/index.html @@ -76,7 +76,7 @@ + +
+{% block content %}{% endblock content %} +
+ +{# ── Ticket slide panel ───────────────────────── #} +
+ + +{# ── Student slide panel ──────────────────────── #} +
+ + + + +{% block extra_scripts %}{% endblock extra_scripts %} + + + diff --git a/apps/mantis_explorer/templates/index.html b/apps/mantis_explorer/templates/index.html new file mode 100644 index 0000000..9467e65 --- /dev/null +++ b/apps/mantis_explorer/templates/index.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} +{% block title %}Mantis Explorer — Overview{% endblock title %} + +{% block content %} + +{# Hidden filter form — included by all HTMX regions #} +
+ + +
+ +{# ── Filter bar ─────────────────────────────────────────── #} +
+ + + + + +
+ +{# ── Stat row ───────────────────────────────────────────── #} +
+
+
Loading…
+
+
+ +{# ── Institution table ──────────────────────────────────── #} +
+
+

Institutions

+ + + {# Hidden sort state #} + + +
+
+ + + + + + + + + + + + + + +
InstitutionStudentsTicketsEscalatedNotesDate Range
Loading…
+
+
+ +{# ── Chart grid ─────────────────────────────────────────── #} +
+ + {# Orgs bar #} +
+
Tickets by Institution
+
Loading…
+
+ + {# Timeline #} +
+
Submissions Over Time
+
Loading…
+
+ + {# Escalation rate #} +
+
Escalations by Institution
+
Loading…
+
+ +
+ +{% endblock content %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/apps/mantis_explorer/templates/org.html b/apps/mantis_explorer/templates/org.html new file mode 100644 index 0000000..b823922 --- /dev/null +++ b/apps/mantis_explorer/templates/org.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}{{ org_name }} — Mantis Explorer{% endblock title %} + +{% block content %} + +{# Hidden filter form — included by all HTMX regions #} +
+ + +
+ +{# Hidden state for student table sort + slug #} + + + + +{# ── Breadcrumb ─────────────────────────────────────────── #} + + +{# ── Filter bar ─────────────────────────────────────────── #} +
+ + + + + +
+ +{# ── Stat row ───────────────────────────────────────────── #} +
+
+
Loading…
+
+
+ +{# ── Org timeline chart — full width ────────────────────── #} +
+
Submission Timeline
+
Loading…
+
+ +{# ── Student table ──────────────────────────────────────── #} +
+
+

Students

+ + +
+
+ + + + + + + + + + + + + + +
NameTicketsEscalatedNotesTotal ActivityDate Range
Loading…
+
+
+ +{% endblock content %} + +{% block extra_scripts %} + +{% endblock extra_scripts %} diff --git a/apps/mantis_explorer/templates/partials/chart_escalation.html b/apps/mantis_explorer/templates/partials/chart_escalation.html new file mode 100644 index 0000000..2ac6598 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_escalation.html @@ -0,0 +1,59 @@ +
Escalations by Institution
+
+{% if esc.orgs %} +
+ +{% else %} +
No data available.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/chart_orgs_bar.html b/apps/mantis_explorer/templates/partials/chart_orgs_bar.html new file mode 100644 index 0000000..d67a420 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_orgs_bar.html @@ -0,0 +1,38 @@ +
Tickets by Institution
+
+{% if rows %} +
+ +{% else %} +
No data available.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/chart_timeline.html b/apps/mantis_explorer/templates/partials/chart_timeline.html new file mode 100644 index 0000000..513da52 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/chart_timeline.html @@ -0,0 +1,45 @@ +
Submissions Over Time
+
+{% if timeline.labels %} +
+ +{% else %} +
No dated tickets in range.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/org_stat_row.html b/apps/mantis_explorer/templates/partials/org_stat_row.html new file mode 100644 index 0000000..855bafb --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_stat_row.html @@ -0,0 +1,24 @@ +
+
+ {{ stats.student_count }} + Students +
+
+ {{ stats.ticket_count }} + Tickets +
+
+ {{ stats.escalation_count }} + Escalated +
+
+ {{ stats.note_count }} + Notes +
+ {% if stats.date_range %} +
+ + {{ stats.date_range }} +
+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/org_table.html b/apps/mantis_explorer/templates/partials/org_table.html new file mode 100644 index 0000000..686d6ae --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_table.html @@ -0,0 +1,18 @@ +{% if rows %} +{% for row in rows %} + + {{ row.name }} + {{ row.student_count }} + {{ row.ticket_count }} + + {{ row.escalation_count }} + + + {{ row.note_count }} + + {{ row.date_range or '—' }} + +{% endfor %} +{% else %} +No institutions found. +{% endif %} diff --git a/apps/mantis_explorer/templates/partials/org_timeline.html b/apps/mantis_explorer/templates/partials/org_timeline.html new file mode 100644 index 0000000..6bf3b26 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/org_timeline.html @@ -0,0 +1,39 @@ +
+ + Submission Timeline — {{ org_name }} +
+
+{% if timeline.labels %} +
+ +{% else %} +
No dated tickets in range.
+{% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/stat_row.html b/apps/mantis_explorer/templates/partials/stat_row.html new file mode 100644 index 0000000..4428582 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/stat_row.html @@ -0,0 +1,28 @@ +
+
+ {{ stats.institution_count }} + Institutions +
+
+ {{ stats.student_count }} + Students +
+
+ {{ stats.ticket_count }} + Tickets +
+
+ {{ stats.escalation_count }} + Escalated +
+
+ {{ stats.note_count }} + Notes +
+ {% if stats.date_range %} +
+ + {{ stats.date_range }} +
+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/student_panel.html b/apps/mantis_explorer/templates/partials/student_panel.html new file mode 100644 index 0000000..aa1888c --- /dev/null +++ b/apps/mantis_explorer/templates/partials/student_panel.html @@ -0,0 +1,110 @@ +{# Student detail panel body — loaded via HTMX into #student-panel-content #} + +{# ── Mini stat row ─────────────────────────────────────── #} +
+
+ {{ student.tickets_created }} + Created +
+
+ {{ student.escalated_tickets }} + Escalated +
+
+ {{ student.notes_written }} + Notes +
+
+ {{ student.total_activity }} + Total +
+
+ +{# ── Tickets created ────────────────────────────────────── #} +
+

+ + Tickets Created + + ({{ student.created_tickets | length }}) + +

+ + {% if student.created_tickets %} + + + + + + + + + + + {% for t in student.created_tickets %} + + + + + + + {% endfor %} + +
IDSummaryStatusCreated
+ #{{ t.id }} + {% if t.is_escalated %} + + {% endif %} + {{ t.summary or '—' }}{{ t.status or '—' }} + {{ (t.created_at or '')[:10] or '—' }} +
+ {% else %} +

No tickets created.

+ {% endif %} +
+ +{# ── Tickets commented on ───────────────────────────────── #} +
+

+ + Tickets Commented On + + ({{ student.noted_tickets | length }}) + +

+ + {% if student.noted_tickets %} + + + + + + + + + + {% for t in student.noted_tickets %} + + + + + + {% endfor %} + +
IDSummaryStatus
#{{ t.id }}{{ t.summary or '—' }}{{ t.status or '—' }}
+ {% else %} +

No other tickets commented on.

+ {% endif %} +
diff --git a/apps/mantis_explorer/templates/partials/student_rows.html b/apps/mantis_explorer/templates/partials/student_rows.html new file mode 100644 index 0000000..db17955 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/student_rows.html @@ -0,0 +1,20 @@ +{% if rows %} +{% for row in rows %} + + {{ row.name }} + {{ row.tickets_created }} + + {{ row.escalated_tickets }} + + + {{ row.notes_written }} + + {{ row.total_activity }} + {{ row.date_range or '—' }} + +{% endfor %} +{% else %} +No students found. +{% endif %} diff --git a/apps/mantis_explorer/templates/partials/ticket_detail.html b/apps/mantis_explorer/templates/partials/ticket_detail.html new file mode 100644 index 0000000..c0fd726 --- /dev/null +++ b/apps/mantis_explorer/templates/partials/ticket_detail.html @@ -0,0 +1,201 @@ +{# Full ticket detail rendered into the slide panel body #} + +{# Meta row — status, severity, project, date #} +
+ {% set status = t.status or '' %} + {% if status in ('resolved', 'closed') %} + {{ status }} + {% elif status in ('new', 'acknowledged') %} + {{ status }} + {% else %} + {{ status or '—' }} + {% endif %} + + {% set sev = (t.severity or '')|lower %} + {% if sev == 'critical' %} + {{ t.severity }} + {% elif sev == 'major' %} + {{ t.severity }} + {% elif sev == 'minor' %} + {{ t.severity }} + {% else %} + {{ t.severity or '—' }} + {% endif %} + + {% if t.priority %} + {{ t.priority }} + {% endif %} + + {% if t.project %} + + {{ t.project }} + + {% endif %} + + {% if t.is_escalated %} + + Escalated + {% if t.escalated_by %} · {{ t.escalated_by }}{% endif %} + + {% endif %} + + {{ (t.created_at or '')[:10] }} + + {% if t.url %} + + Open in Mantis + + {% endif %} +
+ +{# Reporter / handler #} +{% set reporter_name = (t.reporter or {}).get('name') or (t.reporter if t.reporter is string else None) %} +{% set handler_name = (t.handler or {}).get('name') or (t.handler if t.handler is string else None) %} +{% if reporter_name or handler_name %} +
+ {% if reporter_name %} + Reporter: {{ reporter_name }} + {% endif %} + {% if handler_name %} + Handler: {{ handler_name }} + {% endif %} +
+{% endif %} + +{# IP roles — use pre-computed fields when available, fall back to flat list #} +{% set has_roles = t.ip_src is not none or t.ip_dest is not none or t.ip_unknown is not none %} +{% if has_roles %} +
+ {% if t.ip_src %} +
+ + Source + +
+ {% for ip in (t.ip_src or []) %} + {{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if t.ip_dest %} +
+ + Destination + +
+ {% for ip in (t.ip_dest or []) %} + {% set is_private = ip.startswith('10.') or ip.startswith('192.168.') or ip.startswith('172.') %} + {% if is_private %}{% endif %}{{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if t.ip_unknown %} +
+ + Unlabelled + +
+ {% for ip in (t.ip_unknown or []) %} + {% set is_private = ip.startswith('10.') or ip.startswith('192.168.') or ip.startswith('172.') %} + {% if is_private %}{% endif %}{{ ip }} + {% endfor %} +
+
+ {% endif %} + {% if not t.ip_src and not t.ip_dest and not t.ip_unknown %} + No IPs extracted. + {% endif %} +
+{% elif t.ips %} +
+ IPs: + {% for ip in (t.ips or [])[:20] %} + {{ ip }} + {% endfor %} + {% if (t.ips or [])|length > 20 %} + +{{ (t.ips|length) - 20 }} more + {% endif %} +
+{% endif %} + +{# Description #} +{% if t.description and t.description.strip() %} +
+
Description
+
{{ t.description.strip() }}
+
+{% endif %} + +{# Steps to Reproduce #} +{% if t.steps_to_reproduce and t.steps_to_reproduce.strip() %} +
+
Steps to Reproduce
+
{{ t.steps_to_reproduce.strip() }}
+
+{% endif %} + +{# Additional Information #} +{% if t.additional_information and t.additional_information.strip() %} +
+
Additional Information
+
{{ t.additional_information.strip() }}
+
+{% endif %} + +{# External links #} +{% set all_links = ((t.dashboard_links or []) + (t.ti_links or [])) %} +{% if all_links %} +
+ Links: + {% for url in all_links %} + + {% if 'kibana' in url|lower or 'elastic' in url|lower or 'opensearch' in url|lower %} + Dashboard + {% elif 'virustotal' in url|lower %} + VirusTotal + {% else %} + Link + {% endif %} + + {% endfor %} +
+{% endif %} + +{# Notes / Comments #} +{% set notes = t.notes or [] %} +{% if notes %} +
+
+ + Comments ({{ notes|length }}) +
+ {% for note in notes %} + {% set is_admin = note.get('is_admin_note', False) %} + {% set note_reporter = (note.get('reporter') or {}).get('name') or 'Unknown' %} + {% set note_text = (note.get('text') or '').strip() %} + {% if note_text %} +
+
+ {% if is_admin %} + + Admin + + {% endif %} + {{ note_reporter }} + {% set note_date = note.get('date_submitted') or note.get('created_at') or '' %} + {% if note_date %} + {{ note_date[:10] }} + {% endif %} +
+
{{ note_text }}
+
+ {% endif %} + {% endfor %} +
+{% endif %} + +{% if not t.description and not t.steps_to_reproduce and not t.additional_information and not (t.notes or []) %} +

No additional details available for this ticket.

+{% endif %} diff --git a/apps/mantis_web/templates/base.html b/apps/mantis_web/templates/base.html index 247ab06..6a9211e 100644 --- a/apps/mantis_web/templates/base.html +++ b/apps/mantis_web/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}PISCES Threat Modeling{% endblock %} + {% block title %}PISCES Threat Modeling{% endblock title %} -{% block extra_scripts %}{% endblock %} +{% block extra_scripts %}{% endblock extra_scripts %} diff --git a/apps/mantis_web/templates/index.html b/apps/mantis_web/templates/index.html index 3287660..7e2487c 100644 --- a/apps/mantis_web/templates/index.html +++ b/apps/mantis_web/templates/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}PISCES Threat Intel{% endblock %} +{% block title %}PISCES Threat Intel{% endblock title %} {% block content %} @@ -409,4 +409,4 @@ {# ── end tm-tabs-section ── #} -{% endblock %} +{% endblock content %} diff --git a/apps/mantis_web/templates/partials/threat_card.html b/apps/mantis_web/templates/partials/threat_card.html index e263a2c..3f4d3b7 100644 --- a/apps/mantis_web/templates/partials/threat_card.html +++ b/apps/mantis_web/templates/partials/threat_card.html @@ -66,7 +66,7 @@
Summaries - +  
diff --git a/pyproject.toml b/pyproject.toml index 01bb2bd..7767d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "bandit>=1.8.0", "pip-audit>=2.10.0", "pre-commit>=4.6.0", + "djlint>=1.36.4", ] [tool.ruff] @@ -75,5 +76,15 @@ select = ["E", "F", "W", "I"] exclude_dirs = ["tests", "data"] skips = ["B101"] +[tool.djlint] +profile = "jinja" +indent = 2 +max_line_length = 100 +ignore = "H021,H023,H030,H031,J018" +# H021: inline styles are intentional in dashboard/chart templates +# H023: HTML entity references (· etc.) are standard and intentional +# H030/H031: meta description/keywords are false positives on Jinja child templates +# J018: cross-app internal links cannot use url_for() in a multi-app Flask setup + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/run_all.py b/run_all.py index df2c633..9878952 100755 --- a/run_all.py +++ b/run_all.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Combined PISCES Web UI — all three apps on one port via DispatcherMiddleware.""" +"""Combined PISCES Web UI — all apps on one port via DispatcherMiddleware.""" import argparse import os @@ -22,6 +22,7 @@ from apps.dashboard_web.app import create_app as create_dashboard from apps.hub.app import create_app as create_hub +from apps.mantis_explorer.app import create_app as create_mantis_explorer from apps.mantis_web.app import create_app as create_mantis from apps.opensearch_web.app import create_app as create_opensearch @@ -31,6 +32,7 @@ "/opensearch": create_opensearch(), "/mantis": create_mantis(), "/dashboard": create_dashboard(), + "/mantis-explorer": create_mantis_explorer(), }, ) diff --git a/src/mantis/activity_report.py b/src/mantis/activity_report.py new file mode 100644 index 0000000..5de7891 --- /dev/null +++ b/src/mantis/activity_report.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +""" +Student activity report — counts tickets created and notes written per student. + +Students are defined as any reporter who has never acted as a ticket handler +anywhere in the corpus (mirrors the handler_registry logic in mantis_index.py). + +Reads from the local offline index by default; pass --live to fetch from the +REST API instead (requires MANTIS_API_TOKEN + MANTIS_API_URL). + +When --student matches exactly one name, a detailed view is shown with ticket +titles and links. When it matches multiple, the user is prompted to pick one. + +Usage: + python src/mantis/activity_report.py + python src/mantis/activity_report.py --live + python src/mantis/activity_report.py --sort tickets + python src/mantis/activity_report.py --project bonney-lake + python src/mantis/activity_report.py --student alice + python src/mantis/activity_report.py --student alice --graph + python src/mantis/activity_report.py --org 'bellevue college' + python src/mantis/activity_report.py --org 'bellevue college' --student alice + python src/mantis/activity_report.py --since 2025-01-01 --until 2025-04-30 + python src/mantis/activity_report.py --input data/tickets/indexed/tickets_index.json +""" + +import argparse +import json +import os +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import urllib3 +from dotenv import load_dotenv +from rich import box +from rich.console import Console +from rich.rule import Rule +from rich.table import Table + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +_BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DEFAULT_INDEX = os.path.join(_BASE, "data", "tickets", "indexed", "tickets_index.json") + +console = Console() + + +def _ordinal(n: int) -> str: + """Return an integer with its ordinal suffix: 1st, 2nd, 3rd, 4th…""" + if 11 <= (n % 100) <= 13: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + +def _format_date_range(date_strs: list[str]) -> str: + """Format a collection of YYYY-MM-DD strings as a human-readable span. + + Returns strings like "Jan 1st – Apr 28th" for same-year ranges, or + "Jan 1st, 2024 – Apr 28th, 2025" when the range crosses calendar years. + Returns an empty string when no valid dates are present. + """ + valid = [d for d in date_strs if d and len(d) >= 10] + if not valid: + return "" + parsed = sorted(date.fromisoformat(d[:10]) for d in valid) + first, last = parsed[0], parsed[-1] + cross_year = first.year != last.year + + def _fmt(d: date) -> str: + base = f"{d.strftime('%b')} {_ordinal(d.day)}" + return f"{base}, {d.year}" if cross_year else base + + if first == last: + return _fmt(first) + return f"{_fmt(first)} – {_fmt(last)}" + + +def _filter_by_date_range( + tickets: list[dict], + since: date | None, + until: date | None, +) -> list[dict]: + """Return only tickets whose created_at falls within [since, until]. + + Tickets with no or unparseable created_at are kept so data is not silently lost. + """ + if not since and not until: + return tickets + result = [] + for t in tickets: + raw = (t.get("created_at") or "")[:10] + if not raw: + result.append(t) + continue + try: + d = date.fromisoformat(raw) + except ValueError: + result.append(t) + continue + if since and d < since: + continue + if until and d > until: + continue + result.append(t) + return result + + +# Minimal ticket ref stored per student — id, summary, url, status, created_at +_TicketRef = dict + + +@dataclass +class StudentStats: + """Activity data for a single student reporter.""" + + name: str + created_tickets: list[_TicketRef] = field(default_factory=list) + # Tickets they left notes on that they did NOT create (keyed by id to deduplicate) + _noted: dict[str, _TicketRef] = field(default_factory=dict, repr=False) + notes_written: int = 0 + projects: set[str] = field(default_factory=set) + + @property + def tickets_created(self) -> int: + return len(self.created_tickets) + + @property + def noted_tickets(self) -> list[_TicketRef]: + """Unique tickets this student commented on but did not create.""" + return list(self._noted.values()) + + escalated_tickets: int = 0 + categories: set[str] = field(default_factory=set) + + @property + def total_activity(self) -> int: + return self.tickets_created + self.notes_written + + def add_noted(self, ticket: _TicketRef) -> None: + """Record a ticket this student left a note on (deduplicates by id).""" + tid = ticket["id"] + if tid not in self._noted: + self._noted[tid] = ticket + + +def _ticket_ref(ticket: dict) -> _TicketRef: + """Extract the minimal displayable fields from a normalized ticket dict.""" + return { + "id": ticket.get("id", ""), + "summary": ticket.get("summary", ""), + "url": ticket.get("url", ""), + "status": ticket.get("status", ""), + "created_at": ticket.get("created_at", ""), + "is_escalated": bool(ticket.get("is_escalated")), + } + + +def _load_offline(path: str) -> list[dict]: + """Load normalized tickets from the offline index JSON.""" + if not os.path.exists(path): + console.print(f"[red]Offline index not found: {path}[/red]") + console.print("[dim]Run: python src/mantis/mantis_index.py[/dim]") + sys.exit(1) + with open(path) as fh: + return json.load(fh) + + +def _load_live(project_filter: str | None) -> list[dict]: + """Fetch all tickets from the MantisBT REST API.""" + import requests + + from src.mantis.mantis_search import _normalize_issue + + api_url = os.environ.get("MANTIS_API_URL", "").rstrip("/") + api_token = os.environ.get("MANTIS_API_TOKEN", "") + if not api_url or not api_token: + console.print("[red]MANTIS_API_URL and MANTIS_API_TOKEN are required for --live[/red]") + sys.exit(1) + + headers = {"Authorization": api_token} + all_raw: list[dict] = [] + page = 1 + + console.print("[dim]Fetching tickets from Mantis REST API...[/dim]") + while True: + resp = requests.get( + f"{api_url}/api/rest/issues", + headers=headers, + params={"page_size": 200, "page": page}, + timeout=30, + verify=False, + ) + if not resp.ok: + console.print(f"[red]API error {resp.status_code} on page {page}[/red]") + break + data = resp.json() + issues = data.get("issues", []) + if not issues: + break + all_raw.extend(issues) + total = data.get("total_count") + if total and page * 200 >= total: + break + if len(issues) < 200: + break + page += 1 + + console.print(f"[dim]Fetched {len(all_raw)} tickets — normalizing...[/dim]") + handler_registry: set[int] = { + issue["handler"]["id"] for issue in all_raw if issue.get("handler") + } + tickets = [_normalize_issue(issue, api_url, handler_registry) for issue in all_raw] + + if project_filter: + tickets = [t for t in tickets if project_filter.lower() in t.get("project", "").lower()] + + return tickets + + +def build_report( + tickets: list[dict], + project_filter: str | None = None, +) -> dict[int, StudentStats]: + """Aggregate per-student activity from a normalized ticket list. + + Args: + tickets: Normalized ticket dicts from the offline index or live API. + project_filter: Optional substring filter on the project/city field. + + Returns: + Mapping of reporter_id → StudentStats, excluding known handlers. + """ + if project_filter: + tickets = [t for t in tickets if project_filter.lower() in t.get("project", "").lower()] + + # Rebuild handler registry from this corpus + handler_ids: set[int] = set() + for t in tickets: + h = t.get("handler") + if h and h.get("id"): + handler_ids.add(h["id"]) + for note in t.get("notes", []): + if note.get("is_admin_note"): + handler_ids.add(note["reporter"]["id"]) + + stats: dict[int, StudentStats] = defaultdict(lambda: StudentStats(name="")) + + for ticket in tickets: + reporter = ticket.get("reporter", {}) + reporter_id = reporter.get("id", 0) + reporter_name = reporter.get("name", "unknown") + project = ticket.get("project", "") + ref = _ticket_ref(ticket) + + if reporter_id not in handler_ids: + stats[reporter_id].name = reporter_name + stats[reporter_id].created_tickets.append(ref) + if project: + stats[reporter_id].projects.add(project) + if ticket.get("is_escalated"): + stats[reporter_id].escalated_tickets += 1 + category = ticket.get("category", "") + if category: + stats[reporter_id].categories.add(category) + + for note in ticket.get("notes", []): + note_reporter = note.get("reporter", {}) + note_id = note_reporter.get("id", 0) + note_name = note_reporter.get("name", "unknown") + + if note_id in handler_ids: + continue + + stats[note_id].name = note_name + stats[note_id].notes_written += 1 + if project: + stats[note_id].projects.add(project) + # Only track as "noted" if they didn't create it + if note_id != reporter_id: + stats[note_id].add_noted(ref) + + return dict(stats) + + +def _pick_student(matches: list[tuple[int, StudentStats]]) -> tuple[int, StudentStats] | None: + """Prompt the user to select one student from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} students matching that name:[/yellow]\n") + for i, (_, s) in enumerate(matches, 1): + console.print( + f" [cyan]{i}[/cyan]. {s.name} " + f"— {s.tickets_created} ticket(s), {s.notes_written} note(s)" + ) + console.print() + + while True: + try: + raw = input("Select a student (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def _pick_org(matches: list[str]) -> str | None: + """Prompt the user to select one institution from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} institutions matching that name:[/yellow]\n") + for i, name in enumerate(matches, 1): + console.print(f" [cyan]{i}[/cyan]. {name}") + console.print() + + while True: + try: + raw = input("Select an institution (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def display_org_report( + stats: dict[int, StudentStats], + org_filter: str, + sort_by: str = "activity", + student_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the activity report for a single institution. + + Finds all unique ticket categories matching org_filter (substring, + case-insensitive). If multiple categories match, prompts the user to pick + one or show all. Then renders the same summary table as display_report(), + scoped to students whose tickets belong to the matched category/categories. + + If student_filter is also provided, further narrows to a specific student + within the institution and shows the detail view. + + Args: + stats: Full per-student stats from build_report(). + org_filter: Substring to match against ticket category names. + sort_by: Column to sort the summary table by. + student_filter: Optional student name substring for further drill-down. + show_graph: Whether to show the submission graph in detail view. + """ + # Collect all unique categories across the corpus + all_categories: list[str] = sorted( + {cat for s in stats.values() for cat in s.categories}, + key=str.lower, + ) + matching_cats = [c for c in all_categories if org_filter.lower() in c.lower()] + + if not matching_cats: + console.print(f"[yellow]No institution matching '{org_filter}' found.[/yellow]") + console.print(f"[dim]Known institutions: {', '.join(all_categories) or 'none'}[/dim]") + return + + # Disambiguate if multiple categories match + if len(matching_cats) == 1: + chosen_cats = matching_cats + else: + chosen = _pick_org(matching_cats) + chosen_cats = [chosen] if chosen is not None else matching_cats + + org_label = chosen_cats[0] if len(chosen_cats) == 1 else org_filter + + # Filter students who have tickets in any of the chosen categories + org_stats = {k: v for k, v in stats.items() if v.categories & set(chosen_cats)} + + if not org_stats: + console.print(f"[yellow]No students found for institution '{org_label}'.[/yellow]") + return + + all_dates = [ref["created_at"] for s in org_stats.values() for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + date_part = f" · {date_range}" if date_range else "" + console.print( + Rule(f"[bold]{org_label}[/bold] [dim]({len(org_stats)} students{date_part})[/dim]") + ) + + if student_filter: + # Delegate to the normal student-filter path, scoped to this org + display_report( + org_stats, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + else: + display_report(org_stats, sort_by=sort_by) + if show_graph: + console.print() + draw_org_graph(org_stats, org_label) + + +def display_student_detail(student: StudentStats, show_graph: bool = False) -> None: + """Render a detailed activity breakdown for a single student.""" + console.print(Rule(f"[cyan]{student.name}[/cyan]")) + escalated_str = ( + f" Escalated: [red]{student.escalated_tickets}[/red]" if student.escalated_tickets else "" + ) + console.print( + f" Tickets created: [green]{student.tickets_created}[/green]" + f"{escalated_str} " + f"Notes written: [yellow]{student.notes_written}[/yellow] " + f"Total activity: [bold]{student.total_activity}[/bold]" + ) + if student.projects: + console.print(f" Projects: [dim]{', '.join(sorted(student.projects))}[/dim]") + console.print() + + if student.created_tickets: + created = Table(title="Tickets Created", box=box.SIMPLE, show_header=True) + created.add_column("#", style="dim", no_wrap=True) + created.add_column("Summary", ratio=3) + created.add_column("Status", no_wrap=True) + created.add_column("Created", no_wrap=True) + created.add_column("Link", style="blue") + + for ref in sorted( + student.created_tickets, + key=lambda r: int(r["id"]) if r["id"].isdigit() else 0, + reverse=True, + ): + created.add_row( + ref["id"], + ref["summary"] or "—", + ref["status"] or "—", + ref["created_at"] or "—", + ref["url"], + ) + console.print(created) + + noted = student.noted_tickets + if noted: + noted_table = Table( + title="Tickets Commented On (not created by this student)", box=box.SIMPLE + ) + noted_table.add_column("#", style="dim", no_wrap=True) + noted_table.add_column("Summary", ratio=3) + noted_table.add_column("Status", no_wrap=True) + noted_table.add_column("Link", style="blue") + + for ref in sorted( + noted, key=lambda r: int(r["id"]) if r["id"].isdigit() else 0, reverse=True + ): + noted_table.add_row( + ref["id"], + ref["summary"] or "—", + ref["status"] or "—", + ref["url"], + ) + console.print(noted_table) + + if show_graph: + console.print() + draw_submission_graph(student) + + +def _plot_ticket_timeline(date_strs: list[str], title: str) -> None: + """Render a terminal line graph of ticket submissions over time. + + Accepts a flat list of YYYY-MM-DD strings (duplicates allowed — each + represents one ticket). Granularity is chosen automatically: + - ≤ 5 weeks → daily + - ≤ 12 months → weekly (Mon-anchored) + - > 12 months → monthly + + The graph width is capped to the terminal width minus a small margin. + """ + import plotext as plt + + parsed = sorted(date.fromisoformat(d) for d in date_strs) + span_days = (parsed[-1] - parsed[0]).days + + if span_days <= 35: + granularity = "day" + label_fmt = "%b %d" + bucket_fn = lambda d: d # noqa: E731 + elif span_days <= 365: + granularity = "week" + label_fmt = "%b %d" + bucket_fn = lambda d: d - timedelta(days=d.weekday()) # noqa: E731 + else: + granularity = "month" + label_fmt = "%b '%y" + bucket_fn = lambda d: d.replace(day=1) # noqa: E731 + + buckets: dict[date, int] = defaultdict(int) + for d in parsed: + buckets[bucket_fn(d)] += 1 + + # Fill gaps so the x-axis is contiguous + all_buckets = sorted(buckets) + first, last = all_buckets[0], all_buckets[-1] + if granularity == "day": + cursor, step = first, timedelta(days=1) + full_range: list[date] = [] + while cursor <= last: + full_range.append(cursor) + cursor += step + elif granularity == "week": + cursor, step = first, timedelta(weeks=1) + full_range = [] + while cursor <= last: + full_range.append(cursor) + cursor += step + else: + full_range = [] + y, m = first.year, first.month + while date(y, m, 1) <= last: + full_range.append(date(y, m, 1)) + m += 1 + if m > 12: + m, y = 1, y + 1 + + counts = [buckets.get(b, 0) for b in full_range] + labels = [b.strftime(label_fmt) for b in full_range] + # Use numeric x indices — passing formatted strings causes plotext to attempt + # date parsing which fails for short formats like "Jan 12". + x_indices = list(range(len(counts))) + + try: + term_width = os.get_terminal_size().columns + except OSError: + term_width = 100 + plot_width = max(60, min(term_width - 4, 160)) + + tick_step = max(1, len(labels) // (plot_width // 10)) + tick_positions = x_indices[::tick_step] + tick_labels = labels[::tick_step] + + plt.clf() + plt.plot_size(plot_width, 14) + plt.theme("dark") + plt.title(f"{title} ({granularity}ly)") + plt.xlabel(granularity.capitalize()) + plt.ylabel("Tickets") + plt.plot(x_indices, counts, marker="braille") + plt.xticks(tick_positions, tick_labels) + plt.show() + console.print( + f" [dim]{len(parsed)} ticket(s) · " + f"{full_range[0].strftime('%Y-%m-%d')} → {full_range[-1].strftime('%Y-%m-%d')} " + f"· {granularity}ly buckets[/dim]" + ) + + +def draw_submission_graph(student: StudentStats) -> None: + """Render a submission timeline graph for a single student.""" + dated = [ + ref["created_at"] + for ref in student.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {student.name}") + + +def draw_org_graph(org_stats: dict[int, StudentStats], org_label: str) -> None: + """Render an aggregate submission timeline graph for all students in an org.""" + dated = [ + ref["created_at"] + for s in org_stats.values() + for ref in s.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {org_label}") + + +def display_report( + stats: dict[int, StudentStats], + sort_by: str = "activity", + student_filter: str | None = None, + org_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the student activity report. + + If org_filter is set, delegates to display_org_report() which scopes the + table to students from that institution (matched by ticket category). + If student_filter matches exactly one student, shows the detail view. + If it matches multiple, prompts the user to pick one (or show all). + With no filter, renders the full summary table. + """ + if org_filter: + display_org_report( + stats, + org_filter=org_filter, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + return + + if student_filter: + matches = [(k, v) for k, v in stats.items() if student_filter.lower() in v.name.lower()] + + if not matches: + console.print(f"[yellow]No student matching '{student_filter}' found.[/yellow]") + return + + if len(matches) == 1: + display_student_detail(matches[0][1], show_graph=show_graph) + return + + # Multiple matches — prompt + chosen = _pick_student(matches) + if chosen is not None: + display_student_detail(chosen[1], show_graph=show_graph) + return + + # User pressed Enter — fall through and show all matches in summary table + stats = dict(matches) + + rows = list(stats.values()) + + if sort_by == "tickets": + rows.sort(key=lambda s: s.tickets_created, reverse=True) + elif sort_by == "notes": + rows.sort(key=lambda s: s.notes_written, reverse=True) + elif sort_by == "name": + rows.sort(key=lambda s: s.name.lower()) + else: + rows.sort(key=lambda s: s.total_activity, reverse=True) + + all_dates = [ref["created_at"] for s in rows for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + title = f"Student Activity Report ({len(rows)} students)" + if date_range: + title += f" · {date_range}" + + table = Table( + title=title, + box=box.SIMPLE, + show_footer=True, + ) + table.add_column("Student", style="cyan", footer="TOTAL") + table.add_column( + "Tickets Created", + justify="right", + style="green", + footer=str(sum(s.tickets_created for s in rows)), + ) + table.add_column( + "Escalated", + justify="right", + style="red", + footer=str(sum(s.escalated_tickets for s in rows)), + ) + table.add_column( + "Notes Written", + justify="right", + style="yellow", + footer=str(sum(s.notes_written for s in rows)), + ) + table.add_column( + "Total Activity", + justify="right", + footer=str(sum(s.total_activity for s in rows)), + ) + table.add_column("Projects", style="dim") + + for s in rows: + projects_str = ", ".join(sorted(s.projects)) if s.projects else "—" + escalated_str = str(s.escalated_tickets) if s.escalated_tickets else "—" + table.add_row( + s.name, + str(s.tickets_created), + escalated_str, + str(s.notes_written), + str(s.total_activity), + projects_str, + ) + + console.print(table) + + +def main() -> None: + parser = argparse.ArgumentParser(description="PISCES Student Activity Report") + parser.add_argument( + "--input", + default=DEFAULT_INDEX, + help="Path to tickets_index.json (default: data/tickets/indexed/tickets_index.json)", + ) + parser.add_argument( + "--live", + action="store_true", + help="Fetch from Mantis REST API instead of the offline index", + ) + parser.add_argument( + "--project", + metavar="NAME", + help="Filter to tickets in projects matching NAME (substring, e.g. bonney-lake)", + ) + parser.add_argument( + "--sort", + choices=["activity", "tickets", "notes", "name"], + default="activity", + help="Sort order for the summary table (default: activity)", + ) + parser.add_argument( + "--student", + metavar="NAME", + help="Filter to a specific student by name (substring, case-insensitive)", + ) + parser.add_argument( + "--org", + metavar="NAME", + help="Filter to students from a specific institution by category name (substring, " + "case-insensitive, e.g. 'bellevue college')", + ) + parser.add_argument( + "--since", + metavar="YYYY-MM-DD", + help="Include only tickets created on or after this date", + ) + parser.add_argument( + "--until", + metavar="YYYY-MM-DD", + help="Include only tickets created on or before this date", + ) + parser.add_argument( + "--graph", + action="store_true", + help="Show a terminal line graph of ticket submissions over time " + "(requires --student or --org)", + ) + args = parser.parse_args() + + if args.graph and not args.student and not args.org: + parser.error("--graph requires --student or --org") + + since: date | None = None + until: date | None = None + if args.since: + try: + since = date.fromisoformat(args.since) + except ValueError: + parser.error(f"--since: invalid date '{args.since}' (expected YYYY-MM-DD)") + if args.until: + try: + until = date.fromisoformat(args.until) + except ValueError: + parser.error(f"--until: invalid date '{args.until}' (expected YYYY-MM-DD)") + + load_dotenv() + + from src.utils.dns import setup_dns + + setup_dns() + + if args.live: + tickets = _load_live(project_filter=args.project) + stats = build_report(_filter_by_date_range(tickets, since, until)) + else: + tickets = _load_offline(args.input) + stats = build_report( + _filter_by_date_range(tickets, since, until), project_filter=args.project + ) + + display_report( + stats, + sort_by=args.sort, + student_filter=args.student, + org_filter=args.org, + show_graph=args.graph, + ) + + +if __name__ == "__main__": + main() diff --git a/src/mantis/mantis_search.py b/src/mantis/mantis_search.py index d112f02..841ebd9 100755 --- a/src/mantis/mantis_search.py +++ b/src/mantis/mantis_search.py @@ -77,6 +77,59 @@ def sensor_to_project(sensor_val: str) -> str | None: # Fields that carry structured source/dest labels (not free-form notes) _LABEL_FIELDS = ("description", "steps_to_reproduce", "additional_information") +# Escalation detection — two-stage approach applied only to admin notes. +# +# Stage 1 (_CLIENT_CONTACT_RE): the handler directly contacted/informed the +# client (past or present tense). Any match whose 20-char prefix contains +# "will" is treated as a future intent and skipped. +# +# Stage 2 (_ESC_PAST_TO_CLIENT_RE): the handler wrote "escalated [this/it] to +# [the] client" in the past tense. Matches whose 20-char prefix contains +# "not" or "won't" are skipped (negated intent). +# +# Intentionally excluded: +# - bare "escalat*" words (privilege escalation, "no need to escalate", etc.) +# - future-tense client contact ("we will let the client know") +# - conditional escalation ("worth escalating", "if we see more, we'll…") +_CLIENT_CONTACT_RE = re.compile( + r"(?:" + r"(?:informed|notif(?:ied|y))\s+(?:the\s+)?client" + r"|let\s+(?:the\s+)?client\s+know" + r"|message\s+to\s+(?:the\s+)?client" + r"|customer\s+communication" + r"|client\s+respond(?:ed|s)" + r"|reported\s+(?:this\s+)?to\s+(?:the\s+)?client" + r"|reached\s+out\s+to\s+(?:the\s+)?client" + r"|contacted\s+(?:the\s+)?client" + r")", + re.I, +) +_ESC_PAST_TO_CLIENT_RE = re.compile( + r"\bescalated\s+(?:(?:this|it)\s+)?to\s+(?:the\s+)?client\b", + re.I, +) +_WILL_RE = re.compile(r"\bwill\b", re.I) +_NOT_RE = re.compile(r"\bnot\b|\bwon't\b", re.I) + + +def _note_is_escalation(note: dict) -> bool: + """Return True if an admin note confirms the ticket was escalated to the client.""" + text = note.get("text", "") + # Stage 1: client-contact phrases (skip future-tense occurrences) + for m in _CLIENT_CONTACT_RE.finditer(text): + prefix = text[max(0, m.start() - 20) : m.start()] + if _WILL_RE.search(prefix): + continue + return True + # Stage 2: past-tense "escalated [this/it] to [the] client" + for m in _ESC_PAST_TO_CLIENT_RE.finditer(text): + prefix = text[max(0, m.start() - 20) : m.start()] + if _NOT_RE.search(prefix): + continue + return True + return False + + _DASHBOARD_DOMAINS = {"kibana", "opensearch", "elastic"} _TI_DOMAINS = {"greynoise", "abuseipdb", "shodan", "virustotal"} @@ -241,6 +294,11 @@ def _normalize_issue( admin_note_count = sum(1 for n in notes if n["is_admin_note"]) + escalation_note = next( + (n for n in notes if n["is_admin_note"] and _note_is_escalation(n)), + None, + ) + return { "id": issue_id, "url": f"{api_url}/view.php?id={issue_id}", @@ -270,6 +328,8 @@ def _normalize_issue( "ti_links": ti_links, "note_count": len(notes), "admin_note_count": admin_note_count, + "is_escalated": escalation_note is not None, + "escalated_by": escalation_note["reporter"]["name"] if escalation_note else None, } diff --git a/src/mantis/student_activity.py b/src/mantis/student_activity.py index d6b33fb..847f112 100644 --- a/src/mantis/student_activity.py +++ b/src/mantis/student_activity.py @@ -18,6 +18,9 @@ python src/mantis/student_activity.py --project bonney-lake python src/mantis/student_activity.py --student alice python src/mantis/student_activity.py --student alice --graph + python src/mantis/student_activity.py --org 'bellevue college' + python src/mantis/student_activity.py --org 'bellevue college' --student alice + python src/mantis/student_activity.py --since 2025-01-01 --until 2025-04-30 python src/mantis/student_activity.py --input data/tickets/indexed/tickets_index.json """ @@ -45,6 +48,69 @@ console = Console() + +def _ordinal(n: int) -> str: + """Return an integer with its ordinal suffix: 1st, 2nd, 3rd, 4th…""" + if 11 <= (n % 100) <= 13: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + +def _format_date_range(date_strs: list[str]) -> str: + """Format a collection of YYYY-MM-DD strings as a human-readable span. + + Returns strings like "Jan 1st – Apr 28th" for same-year ranges, or + "Jan 1st, 2024 – Apr 28th, 2025" when the range crosses calendar years. + Returns an empty string when no valid dates are present. + """ + valid = [d for d in date_strs if d and len(d) >= 10] + if not valid: + return "" + parsed = sorted(date.fromisoformat(d[:10]) for d in valid) + first, last = parsed[0], parsed[-1] + cross_year = first.year != last.year + + def _fmt(d: date) -> str: + base = f"{d.strftime('%b')} {_ordinal(d.day)}" + return f"{base}, {d.year}" if cross_year else base + + if first == last: + return _fmt(first) + return f"{_fmt(first)} – {_fmt(last)}" + + +def _filter_by_date_range( + tickets: list[dict], + since: date | None, + until: date | None, +) -> list[dict]: + """Return only tickets whose created_at falls within [since, until]. + + Tickets with no or unparseable created_at are kept so data is not silently lost. + """ + if not since and not until: + return tickets + result = [] + for t in tickets: + raw = (t.get("created_at") or "")[:10] + if not raw: + result.append(t) + continue + try: + d = date.fromisoformat(raw) + except ValueError: + result.append(t) + continue + if since and d < since: + continue + if until and d > until: + continue + result.append(t) + return result + + # Minimal ticket ref stored per student — id, summary, url, status, created_at _TicketRef = dict @@ -69,6 +135,9 @@ def noted_tickets(self) -> list[_TicketRef]: """Unique tickets this student commented on but did not create.""" return list(self._noted.values()) + escalated_tickets: int = 0 + categories: set[str] = field(default_factory=set) + @property def total_activity(self) -> int: return self.tickets_created + self.notes_written @@ -193,6 +262,11 @@ def build_report( stats[reporter_id].created_tickets.append(ref) if project: stats[reporter_id].projects.add(project) + if ticket.get("is_escalated"): + stats[reporter_id].escalated_tickets += 1 + category = ticket.get("category", "") + if category: + stats[reporter_id].categories.add(category) for note in ticket.get("notes", []): note_reporter = note.get("reporter", {}) @@ -239,11 +313,112 @@ def _pick_student(matches: list[tuple[int, StudentStats]]) -> tuple[int, Student console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") +def _pick_org(matches: list[str]) -> str | None: + """Prompt the user to select one institution from a list of matches.""" + console.print(f"\n[yellow]Found {len(matches)} institutions matching that name:[/yellow]\n") + for i, name in enumerate(matches, 1): + console.print(f" [cyan]{i}[/cyan]. {name}") + console.print() + + while True: + try: + raw = input("Select an institution (number), or press Enter to show all: ").strip() + except (EOFError, KeyboardInterrupt): + console.print() + return None + + if raw == "": + return None + + if raw.isdigit() and 1 <= int(raw) <= len(matches): + return matches[int(raw) - 1] + + console.print(f"[red]Enter a number between 1 and {len(matches)}.[/red]") + + +def display_org_report( + stats: dict[int, StudentStats], + org_filter: str, + sort_by: str = "activity", + student_filter: str | None = None, + show_graph: bool = False, +) -> None: + """Render the activity report for a single institution. + + Finds all unique ticket categories matching org_filter (substring, + case-insensitive). If multiple categories match, prompts the user to pick + one or show all. Then renders the same summary table as display_report(), + scoped to students whose tickets belong to the matched category/categories. + + If student_filter is also provided, further narrows to a specific student + within the institution and shows the detail view. + + Args: + stats: Full per-student stats from build_report(). + org_filter: Substring to match against ticket category names. + sort_by: Column to sort the summary table by. + student_filter: Optional student name substring for further drill-down. + show_graph: Whether to show the submission graph in detail view. + """ + # Collect all unique categories across the corpus + all_categories: list[str] = sorted( + {cat for s in stats.values() for cat in s.categories}, + key=str.lower, + ) + matching_cats = [c for c in all_categories if org_filter.lower() in c.lower()] + + if not matching_cats: + console.print(f"[yellow]No institution matching '{org_filter}' found.[/yellow]") + console.print(f"[dim]Known institutions: {', '.join(all_categories) or 'none'}[/dim]") + return + + # Disambiguate if multiple categories match + if len(matching_cats) == 1: + chosen_cats = matching_cats + else: + chosen = _pick_org(matching_cats) + chosen_cats = [chosen] if chosen is not None else matching_cats + + org_label = chosen_cats[0] if len(chosen_cats) == 1 else org_filter + + # Filter students who have tickets in any of the chosen categories + org_stats = {k: v for k, v in stats.items() if v.categories & set(chosen_cats)} + + if not org_stats: + console.print(f"[yellow]No students found for institution '{org_label}'.[/yellow]") + return + + all_dates = [ref["created_at"] for s in org_stats.values() for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + date_part = f" · {date_range}" if date_range else "" + console.print( + Rule(f"[bold]{org_label}[/bold] [dim]({len(org_stats)} students{date_part})[/dim]") + ) + + if student_filter: + # Delegate to the normal student-filter path, scoped to this org + display_report( + org_stats, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + else: + display_report(org_stats, sort_by=sort_by) + if show_graph: + console.print() + draw_org_graph(org_stats, org_label) + + def display_student_detail(student: StudentStats, show_graph: bool = False) -> None: """Render a detailed activity breakdown for a single student.""" console.print(Rule(f"[cyan]{student.name}[/cyan]")) + escalated_str = ( + f" Escalated: [red]{student.escalated_tickets}[/red]" if student.escalated_tickets else "" + ) console.print( - f" Tickets created: [green]{student.tickets_created}[/green] " + f" Tickets created: [green]{student.tickets_created}[/green]" + f"{escalated_str} " f"Notes written: [yellow]{student.notes_written}[/yellow] " f"Total activity: [bold]{student.total_activity}[/bold]" ) @@ -299,32 +474,22 @@ def display_student_detail(student: StudentStats, show_graph: bool = False) -> N draw_submission_graph(student) -def draw_submission_graph(student: StudentStats) -> None: - """Render a terminal line graph of ticket submissions over time for one student. +def _plot_ticket_timeline(date_strs: list[str], title: str) -> None: + """Render a terminal line graph of ticket submissions over time. - Granularity is chosen automatically based on the date range: + Accepts a flat list of YYYY-MM-DD strings (duplicates allowed — each + represents one ticket). Granularity is chosen automatically: - ≤ 5 weeks → daily - ≤ 12 months → weekly (Mon-anchored) - > 12 months → monthly - The graph width is capped to the terminal width minus a small margin so it - fits comfortably on most screen sizes. + The graph width is capped to the terminal width minus a small margin. """ import plotext as plt - dated = [ - ref["created_at"] - for ref in student.created_tickets - if ref.get("created_at") and len(ref["created_at"]) == 10 - ] - if not dated: - console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") - return - - parsed = sorted(date.fromisoformat(d) for d in dated) + parsed = sorted(date.fromisoformat(d) for d in date_strs) span_days = (parsed[-1] - parsed[0]).days - # Choose bucket granularity if span_days <= 35: granularity = "day" label_fmt = "%b %d" @@ -338,7 +503,6 @@ def draw_submission_graph(student: StudentStats) -> None: label_fmt = "%b '%y" bucket_fn = lambda d: d.replace(day=1) # noqa: E731 - # Build ordered bucket counts buckets: dict[date, int] = defaultdict(int) for d in parsed: buckets[bucket_fn(d)] += 1 @@ -378,17 +542,15 @@ def draw_submission_graph(student: StudentStats) -> None: except OSError: term_width = 100 plot_width = max(60, min(term_width - 4, 160)) - plot_height = 14 - # Show one x-tick label every N buckets so they don't overlap tick_step = max(1, len(labels) // (plot_width // 10)) tick_positions = x_indices[::tick_step] tick_labels = labels[::tick_step] plt.clf() - plt.plot_size(plot_width, plot_height) + plt.plot_size(plot_width, 14) plt.theme("dark") - plt.title(f"Ticket Submissions — {student.name} ({granularity}ly)") + plt.title(f"{title} ({granularity}ly)") plt.xlabel(granularity.capitalize()) plt.ylabel("Tickets") plt.plot(x_indices, counts, marker="braille") @@ -401,18 +563,58 @@ def draw_submission_graph(student: StudentStats) -> None: ) +def draw_submission_graph(student: StudentStats) -> None: + """Render a submission timeline graph for a single student.""" + dated = [ + ref["created_at"] + for ref in student.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {student.name}") + + +def draw_org_graph(org_stats: dict[int, StudentStats], org_label: str) -> None: + """Render an aggregate submission timeline graph for all students in an org.""" + dated = [ + ref["created_at"] + for s in org_stats.values() + for ref in s.created_tickets + if ref.get("created_at") and len(ref["created_at"]) == 10 + ] + if not dated: + console.print("[yellow]No dated tickets — cannot draw graph.[/yellow]") + return + _plot_ticket_timeline(dated, f"Ticket Submissions — {org_label}") + + def display_report( stats: dict[int, StudentStats], sort_by: str = "activity", student_filter: str | None = None, + org_filter: str | None = None, show_graph: bool = False, ) -> None: """Render the student activity report. + If org_filter is set, delegates to display_org_report() which scopes the + table to students from that institution (matched by ticket category). If student_filter matches exactly one student, shows the detail view. If it matches multiple, prompts the user to pick one (or show all). With no filter, renders the full summary table. """ + if org_filter: + display_org_report( + stats, + org_filter=org_filter, + sort_by=sort_by, + student_filter=student_filter, + show_graph=show_graph, + ) + return + if student_filter: matches = [(k, v) for k, v in stats.items() if student_filter.lower() in v.name.lower()] @@ -444,8 +646,14 @@ def display_report( else: rows.sort(key=lambda s: s.total_activity, reverse=True) + all_dates = [ref["created_at"] for s in rows for ref in s.created_tickets] + date_range = _format_date_range(all_dates) + title = f"Student Activity Report ({len(rows)} students)" + if date_range: + title += f" · {date_range}" + table = Table( - title=f"Student Activity Report ({len(rows)} students)", + title=title, box=box.SIMPLE, show_footer=True, ) @@ -456,6 +664,12 @@ def display_report( style="green", footer=str(sum(s.tickets_created for s in rows)), ) + table.add_column( + "Escalated", + justify="right", + style="red", + footer=str(sum(s.escalated_tickets for s in rows)), + ) table.add_column( "Notes Written", justify="right", @@ -471,9 +685,11 @@ def display_report( for s in rows: projects_str = ", ".join(sorted(s.projects)) if s.projects else "—" + escalated_str = str(s.escalated_tickets) if s.escalated_tickets else "—" table.add_row( s.name, str(s.tickets_created), + escalated_str, str(s.notes_written), str(s.total_activity), projects_str, @@ -510,15 +726,45 @@ def main() -> None: metavar="NAME", help="Filter to a specific student by name (substring, case-insensitive)", ) + parser.add_argument( + "--org", + metavar="NAME", + help="Filter to students from a specific institution by category name (substring, " + "case-insensitive, e.g. 'bellevue college')", + ) + parser.add_argument( + "--since", + metavar="YYYY-MM-DD", + help="Include only tickets created on or after this date", + ) + parser.add_argument( + "--until", + metavar="YYYY-MM-DD", + help="Include only tickets created on or before this date", + ) parser.add_argument( "--graph", action="store_true", - help="Show a terminal line graph of ticket submissions over time (requires --student)", + help="Show a terminal line graph of ticket submissions over time " + "(requires --student or --org)", ) args = parser.parse_args() - if args.graph and not args.student: - parser.error("--graph requires --student") + if args.graph and not args.student and not args.org: + parser.error("--graph requires --student or --org") + + since: date | None = None + until: date | None = None + if args.since: + try: + since = date.fromisoformat(args.since) + except ValueError: + parser.error(f"--since: invalid date '{args.since}' (expected YYYY-MM-DD)") + if args.until: + try: + until = date.fromisoformat(args.until) + except ValueError: + parser.error(f"--until: invalid date '{args.until}' (expected YYYY-MM-DD)") load_dotenv() @@ -528,12 +774,20 @@ def main() -> None: if args.live: tickets = _load_live(project_filter=args.project) - stats = build_report(tickets) + stats = build_report(_filter_by_date_range(tickets, since, until)) else: tickets = _load_offline(args.input) - stats = build_report(tickets, project_filter=args.project) + stats = build_report( + _filter_by_date_range(tickets, since, until), project_filter=args.project + ) - display_report(stats, sort_by=args.sort, student_filter=args.student, show_graph=args.graph) + display_report( + stats, + sort_by=args.sort, + student_filter=args.student, + org_filter=args.org, + show_graph=args.graph, + ) if __name__ == "__main__": diff --git a/src/querier/zeek_modules/__init__.py b/src/querier/zeek_modules/__init__.py index 38331d2..686e0a9 100644 --- a/src/querier/zeek_modules/__init__.py +++ b/src/querier/zeek_modules/__init__.py @@ -41,8 +41,8 @@ "smb": SmbModule(), "ssh": SshModule(), "notice": NoticeModule(), - "weird": WeirdModule(), "suricata_alert": SuricataAlertModule(), + "weird": WeirdModule(), "files": FilesModule(), "x509": X509Module(), "pe": PEModule(), diff --git a/src/querier/zeek_modules/base.py b/src/querier/zeek_modules/base.py index d57b19f..aa3bfc3 100644 --- a/src/querier/zeek_modules/base.py +++ b/src/querier/zeek_modules/base.py @@ -264,6 +264,7 @@ def build_base_query( datasets: list, public_only: bool = False, src_ip_filter: str | None = None, + dest_ip_filter: str | None = None, direction: str | None = None, time_from: str | None = None, time_to: str | None = None, @@ -289,6 +290,9 @@ def build_base_query( if src_ip_filter: must_clauses.append({"term": {"source.ip": src_ip_filter}}) + if dest_ip_filter: + must_clauses.append({"term": {"destination.ip": dest_ip_filter}}) + if direction: must_clauses.append({"term": {"network.direction": direction}}) @@ -370,9 +374,12 @@ def run_query(module, search_params: dict) -> list: extra_must, post_filters = module.build_extra_must(search_params) - # Guard src_ip_filter for modules without source.ip in SOURCE_FIELDS — + # Guard src/dest ip filters for modules that don't have the field in SOURCE_FIELDS — # a term query on a missing field returns zero results. src_ip_for_query = search_params.get("src_ip") if "source.ip" in module.SOURCE_FIELDS else None + dest_ip_for_query = ( + search_params.get("dest_ip") if "destination.ip" in module.SOURCE_FIELDS else None + ) # Over-fetch when post-filters are active so truncation still yields enough rows. requested_limit = search_params.get("limit", 500) @@ -388,6 +395,7 @@ def run_query(module, search_params: dict) -> list: datasets=module.DATASETS, public_only=search_params.get("public_only", False), src_ip_filter=src_ip_for_query, + dest_ip_filter=dest_ip_for_query, direction=search_params.get("direction"), time_from=search_params.get("time_from"), time_to=search_params.get("time_to"), diff --git a/uv.lock b/uv.lock index 1ca8de0..333a256 100644 --- a/uv.lock +++ b/uv.lock @@ -494,6 +494,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] +[[package]] +name = "cssbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "jsbeautifier" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98", size = 123667, upload-time = "2025-02-27T17:53:43.594Z" }, +] + [[package]] name = "cyclonedx-python-lib" version = "11.7.0" @@ -576,6 +590,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "djlint" +version = "1.36.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama" }, + { name = "cssbeautifier" }, + { name = "jsbeautifier" }, + { name = "json5" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b", size = 354886, upload-time = "2024-12-24T13:06:11.571Z" }, + { url = "https://files.pythonhosted.org/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e", size = 323237, upload-time = "2024-12-24T13:06:13.057Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675", size = 411719, upload-time = "2024-12-24T13:06:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08", size = 362076, upload-time = "2024-12-24T13:06:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" }, + { url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" }, +] + +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, +] + [[package]] name = "filelock" version = "3.28.0" @@ -799,6 +850,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, +] + +[[package]] +name = "json5" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1341,6 +1414,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, +] + [[package]] name = "pip" version = "26.0.1" @@ -1435,6 +1517,7 @@ offline-enrichment = [ [package.dev-dependencies] dev = [ { name = "bandit" }, + { name = "djlint" }, { name = "pip-audit" }, { name = "pre-commit" }, { name = "pyright" }, @@ -1447,7 +1530,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "cryptography", specifier = ">=46.0.7" }, { name = "flask", specifier = ">=3.0" }, - { name = "geoip2", specifier = ">=4.8.0" }, + { name = "geoip2", specifier = ">=5.2.0" }, { name = "mcp", extras = ["cli"], marker = "extra == 'all'", specifier = ">=1.0.0" }, { name = "mcp", extras = ["cli"], marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "orjson", specifier = ">=3.11.8" }, @@ -1460,7 +1543,7 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.26" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.31.0" }, - { name = "rich", specifier = ">=13.0.0" }, + { name = "rich", specifier = ">=15.0.0" }, { name = "spacy", marker = "extra == 'all'", specifier = ">=3.7" }, { name = "spacy", marker = "extra == 'nlp'", specifier = ">=3.7" }, ] @@ -1469,11 +1552,12 @@ provides-extras = ["mcp", "nlp", "offline-enrichment", "all"] [package.metadata.requires-dev] dev = [ { name = "bandit", specifier = ">=1.8.0" }, + { name = "djlint", specifier = ">=1.36.4" }, { name = "pip-audit", specifier = ">=2.10.0" }, - { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pyright", specifier = ">=1.1.408" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "ruff", specifier = ">=0.15.6" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.15.11" }, ] [[package]] @@ -1505,7 +1589,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1514,9 +1598,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -1942,6 +2026,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1959,15 +2131,15 @@ wheels = [ [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -2053,27 +2225,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -2094,6 +2266,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "smart-open" version = "7.5.1"