feedback-hub 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ """feedback-hub - Multi-framework GitHub issue submission library.
2
+
3
+ Each app uses its native UI; all submit directly to GitHub Issues.
4
+
5
+ Quick start (wxPython)::
6
+
7
+ from feedback_hub import load_schema
8
+ from feedback_hub.wx_dialog import FeedbackDialog
9
+
10
+ schema = load_schema({"app": "MyApp", "github_repo": "org/repo", "fields": [...]})
11
+ dlg = FeedbackDialog(parent, schema=schema, github_token=TOKEN)
12
+ dlg.ShowModal()
13
+ dlg.Destroy()
14
+
15
+ Quick start (Flask)::
16
+
17
+ from feedback_hub.flask_blueprint import make_blueprint
18
+
19
+ feedback_bp = make_blueprint(app_name="MyApp", github_repo="org/repo")
20
+ app.register_blueprint(feedback_bp, url_prefix="/feedback")
21
+
22
+ Quick start (headless / CLI)::
23
+
24
+ from feedback_hub import submit
25
+
26
+ issue_url, error = submit(
27
+ app="MyApp",
28
+ github_repo="org/repo",
29
+ github_token=TOKEN,
30
+ summary="Something is broken",
31
+ message="Details here",
32
+ category="Bug Report",
33
+ )
34
+ """
35
+ from feedback_hub._github import GitHubConfig, resolve_token
36
+ from feedback_hub._schema import AppSchema, FieldSchema, build_entry, load_schema
37
+ from feedback_hub._storage import list_all, save
38
+
39
+ __version__ = "1.0.0"
40
+
41
+ __all__ = [
42
+ "AppSchema",
43
+ "FieldSchema",
44
+ "GitHubConfig",
45
+ "build_entry",
46
+ "list_all",
47
+ "load_schema",
48
+ "resolve_token",
49
+ "save",
50
+ "submit",
51
+ ]
52
+
53
+
54
+ def submit(
55
+ *,
56
+ app: str,
57
+ github_repo: str,
58
+ github_token: str = "",
59
+ summary: str = "",
60
+ message: str,
61
+ category: str = "feedback",
62
+ name: str = "",
63
+ email: str = "",
64
+ app_version: str = "",
65
+ github_labels: list[str] | None = None,
66
+ github_assignee: str = "",
67
+ metadata: dict | None = None,
68
+ db_path=None,
69
+ ) -> tuple:
70
+ """Headless submission -- no UI required.
71
+
72
+ Returns ``(issue_url, error_message)``.
73
+ """
74
+ from datetime import UTC, datetime
75
+ from pathlib import Path
76
+
77
+ from feedback_hub._github import create_issue
78
+ from feedback_hub._storage import save as _save, update_github_sync
79
+
80
+ token = resolve_token(github_token)
81
+ entry = {
82
+ "app": app,
83
+ "version": app_version,
84
+ "platform": "",
85
+ "category": category,
86
+ "name": name,
87
+ "email": email,
88
+ "summary": summary,
89
+ "message": message,
90
+ "metadata": metadata,
91
+ "timestamp": datetime.now(UTC).isoformat(),
92
+ }
93
+
94
+ _db = db_path or Path.home() / ".local" / "share" / "feedback-hub" / "feedback.db"
95
+ try:
96
+ row_id = _save(entry, Path(_db))
97
+ except Exception:
98
+ row_id = None
99
+
100
+ if not token:
101
+ return None, "GitHub token not configured"
102
+
103
+ cfg = GitHubConfig(
104
+ token=token,
105
+ repo=github_repo,
106
+ assignee=github_assignee,
107
+ labels=github_labels or ["needs-triage"],
108
+ )
109
+ number, url, error = create_issue(entry, cfg)
110
+
111
+ if row_id is not None:
112
+ try:
113
+ update_github_sync(row_id, issue_number=number, issue_url=url, error=error, db_path=Path(_db))
114
+ except Exception:
115
+ pass
116
+
117
+ return url, error
@@ -0,0 +1,148 @@
1
+ """GitHub Issues API client.
2
+
3
+ Framework-agnostic. Accepts a config and a prepared entry dict,
4
+ posts it as a GitHub issue, returns (issue_number, issue_url, error).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional
12
+ from urllib import error as urlerror
13
+ from urllib import request as urlrequest
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class GitHubConfig:
18
+ token: str
19
+ repo: str
20
+ assignee: str = ""
21
+ labels: list[str] = field(default_factory=lambda: ["needs-triage"])
22
+
23
+
24
+ def resolve_token(*candidates: str) -> str:
25
+ """Resolve GitHub token from candidates in priority order.
26
+
27
+ Candidates are tried first. Then standard env vars are checked.
28
+ This lets each app pass its own bundled fine-grained PAT as the
29
+ last candidate while still allowing env var overrides.
30
+
31
+ Safe to bundle issues-only fine-grained PATs in desktop apps:
32
+ worst case misuse is filing extra issues, not code/repo access.
33
+ """
34
+ for candidate in candidates:
35
+ if candidate and candidate.strip():
36
+ return candidate.strip()
37
+ for env_var in (
38
+ "FEEDBACK_HUB_GITHUB_TOKEN",
39
+ "SUPPORT_HUB_GITHUB_TOKEN",
40
+ "FEEDBACK_GITHUB_TOKEN",
41
+ ):
42
+ value = os.environ.get(env_var, "").strip()
43
+ if value:
44
+ return value
45
+ return ""
46
+
47
+
48
+ def create_issue(
49
+ entry: dict[str, object],
50
+ config: GitHubConfig,
51
+ ) -> tuple[Optional[int], Optional[str], Optional[str]]:
52
+ """Post entry as a GitHub issue.
53
+
54
+ Returns (issue_number, issue_url, error_message).
55
+ On success error_message is None; on failure number and url are None.
56
+ """
57
+ if not config.token:
58
+ return None, None, "GitHub token not configured"
59
+
60
+ payload = _build_payload(entry, config)
61
+ req = urlrequest.Request(
62
+ f"https://api.github.com/repos/{config.repo}/issues",
63
+ data=json.dumps(payload).encode("utf-8"),
64
+ method="POST",
65
+ headers={
66
+ "Accept": "application/vnd.github+json",
67
+ "Authorization": f"Bearer {config.token}",
68
+ "X-GitHub-Api-Version": "2022-11-28",
69
+ "Content-Type": "application/json",
70
+ "User-Agent": "feedback-hub/1.0",
71
+ },
72
+ )
73
+ try:
74
+ with urlrequest.urlopen(req, timeout=20) as resp:
75
+ data = json.loads(resp.read().decode("utf-8"))
76
+ return data.get("number"), data.get("html_url"), None
77
+ except urlerror.HTTPError as exc:
78
+ try:
79
+ details = exc.read().decode("utf-8")
80
+ except Exception:
81
+ details = str(exc)
82
+ return None, None, f"GitHub API error {exc.code}: {details}"
83
+ except Exception as exc: # noqa: BLE001
84
+ return None, None, f"GitHub request failed: {exc}"
85
+
86
+
87
+ def _build_payload(entry: dict[str, object], cfg: GitHubConfig) -> dict[str, object]:
88
+ app = str(entry.get("app", "Unknown")).strip()
89
+ category = str(entry.get("category", "feedback")).strip().lower()
90
+ summary = str(entry.get("summary") or entry.get("message", ""))[:120].strip()
91
+ title = f"[{app}] {category}: {summary}"
92
+
93
+ lines = [
94
+ "## Feedback Report",
95
+ "",
96
+ f"- **App**: `{app}`",
97
+ f"- **Version**: `{entry.get('version', 'unknown')}`",
98
+ f"- **Platform**: `{entry.get('platform', 'unknown')}`",
99
+ f"- **Category**: `{category}`",
100
+ f"- **Submitted**: `{entry.get('timestamp', 'unknown')}`",
101
+ ]
102
+
103
+ submitter_name = str(entry.get("name", "")).strip()
104
+ submitter_email = str(entry.get("email", "")).strip()
105
+ if submitter_name or submitter_email:
106
+ lines.append("")
107
+ lines.append("### Submitter")
108
+ if submitter_name:
109
+ lines.append(f"- **Name**: {submitter_name}")
110
+ if submitter_email:
111
+ lines.append(f"- **Email**: {submitter_email}")
112
+
113
+ lines += ["", "### Details", "", str(entry.get("message", "")).strip()]
114
+
115
+ extra_fields = entry.get("extra_fields")
116
+ if isinstance(extra_fields, dict):
117
+ rendered = []
118
+ for label, value in extra_fields.items():
119
+ if value and str(value).strip():
120
+ rendered.append(f"**{label}**: {value}")
121
+ if rendered:
122
+ lines += ["", "### Additional Information", ""]
123
+ lines += rendered
124
+
125
+ metadata = entry.get("metadata")
126
+ if isinstance(metadata, dict) and metadata:
127
+ lines += [
128
+ "",
129
+ "### Environment",
130
+ "```",
131
+ json.dumps(metadata, indent=2, sort_keys=True),
132
+ "```",
133
+ ]
134
+
135
+ lines += [
136
+ "",
137
+ "---",
138
+ "_Submitted via feedback-hub_",
139
+ ]
140
+
141
+ payload: dict[str, object] = {
142
+ "title": title,
143
+ "body": "\n".join(lines),
144
+ "labels": cfg.labels,
145
+ }
146
+ if cfg.assignee:
147
+ payload["assignees"] = [cfg.assignee]
148
+ return payload
@@ -0,0 +1,160 @@
1
+ """Dynamic form schema system.
2
+
3
+ Each app defines a schema (dict or JSON file) describing which fields
4
+ to show and how to validate them. The schema drives:
5
+ - wxPython dialog field generation
6
+ - Flask form rendering
7
+ - Validation rules
8
+ - GitHub issue formatting
9
+
10
+ Minimal schema example::
11
+
12
+ {
13
+ "app": "ChapterForge",
14
+ "version": "2.0.0",
15
+ "github_repo": "BITS-ACB/chapterforge",
16
+ "github_labels": ["bug", "needs-triage"],
17
+ "categories": ["Bug Report", "Feature Request", "Accessibility Issue", "Other"],
18
+ "fields": [
19
+ {"name": "summary", "label": "Summary", "type": "text", "required": true, "max_length": 120},
20
+ {"name": "message", "label": "What happened", "type": "textarea", "required": true, "max_length": 5000},
21
+ {"name": "expected", "label": "What you expected","type": "textarea", "required": false},
22
+ {"name": "steps", "label": "Steps to reproduce","type": "textarea","required": false},
23
+ {"name": "version", "label": "App version", "type": "text", "required": false, "default": "__app_version__"},
24
+ {"name": "platform", "label": "Operating system", "type": "text", "required": false, "default": "__platform__"}
25
+ ]
26
+ }
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import platform
32
+ import sys
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any, Optional
36
+
37
+
38
+ @dataclass
39
+ class FieldSchema:
40
+ name: str
41
+ label: str
42
+ type: str = "text" # text | textarea | select | checkbox
43
+ required: bool = False
44
+ max_length: int = 0 # 0 = no limit
45
+ options: list[str] = field(default_factory=list) # for select
46
+ default: str = ""
47
+ placeholder: str = ""
48
+
49
+
50
+ @dataclass
51
+ class AppSchema:
52
+ app: str
53
+ github_repo: str
54
+ github_labels: list[str] = field(default_factory=lambda: ["needs-triage"])
55
+ github_assignee: str = ""
56
+ version: str = ""
57
+ categories: list[str] = field(default_factory=lambda: [
58
+ "Bug Report", "Feature Request", "Accessibility Issue", "Other"
59
+ ])
60
+ fields: list[FieldSchema] = field(default_factory=list)
61
+
62
+ def resolve_defaults(self, app_version: str = "") -> dict[str, str]:
63
+ """Return a dict of field name -> resolved default value."""
64
+ os_str = f"{platform.system()} {platform.release()}"
65
+ py_str = sys.version.splitlines()[0]
66
+ magic = {
67
+ "__app_version__": app_version or self.version,
68
+ "__platform__": os_str,
69
+ "__python__": py_str,
70
+ }
71
+ return {
72
+ f.name: magic.get(f.default, f.default)
73
+ for f in self.fields
74
+ }
75
+
76
+ def validate(self, values: dict[str, str]) -> list[str]:
77
+ errors = []
78
+ for f in self.fields:
79
+ val = values.get(f.name, "").strip()
80
+ if f.required and not val:
81
+ errors.append(f"{f.label} is required.")
82
+ if f.max_length and len(val) > f.max_length:
83
+ errors.append(f"{f.label} must be {f.max_length} characters or fewer.")
84
+ if f.type == "select" and f.options and val and val not in f.options:
85
+ errors.append(f"{f.label} has an invalid value.")
86
+ return errors
87
+
88
+
89
+ def load_schema(source: str | dict | Path) -> AppSchema:
90
+ """Load a schema from a dict, JSON string, or Path to a JSON file."""
91
+ if isinstance(source, Path):
92
+ raw = json.loads(source.read_text(encoding="utf-8"))
93
+ elif isinstance(source, str):
94
+ raw = json.loads(source)
95
+ else:
96
+ raw = source
97
+
98
+ fields = [
99
+ FieldSchema(
100
+ name=f["name"],
101
+ label=f.get("label", f["name"].title()),
102
+ type=f.get("type", "text"),
103
+ required=f.get("required", False),
104
+ max_length=f.get("max_length", 0),
105
+ options=f.get("options", []),
106
+ default=f.get("default", ""),
107
+ placeholder=f.get("placeholder", ""),
108
+ )
109
+ for f in raw.get("fields", [])
110
+ ]
111
+
112
+ return AppSchema(
113
+ app=raw["app"],
114
+ github_repo=raw["github_repo"],
115
+ github_labels=raw.get("github_labels", ["needs-triage"]),
116
+ github_assignee=raw.get("github_assignee", ""),
117
+ version=raw.get("version", ""),
118
+ categories=raw.get("categories", [
119
+ "Bug Report", "Feature Request", "Accessibility Issue", "Other"
120
+ ]),
121
+ fields=fields,
122
+ )
123
+
124
+
125
+ def build_entry(
126
+ schema: AppSchema,
127
+ values: dict[str, str],
128
+ *,
129
+ name: str = "",
130
+ email: str = "",
131
+ category: str = "",
132
+ app_version: str = "",
133
+ metadata: Optional[dict[str, Any]] = None,
134
+ timestamp: str = "",
135
+ ) -> dict[str, object]:
136
+ """Build a normalized entry dict from submitted form values."""
137
+ from datetime import UTC, datetime
138
+
139
+ # The fields listed in the schema go into extra_fields so the
140
+ # GitHub formatter can render them with their human labels.
141
+ extra_fields = {
142
+ schema_field.label: values.get(schema_field.name, "")
143
+ for schema_field in schema.fields
144
+ if schema_field.name not in ("summary", "message", "version", "platform")
145
+ and values.get(schema_field.name, "").strip()
146
+ }
147
+
148
+ return {
149
+ "app": schema.app,
150
+ "version": values.get("version", app_version or schema.version),
151
+ "platform": values.get("platform", ""),
152
+ "category": category or "feedback",
153
+ "name": name,
154
+ "email": email,
155
+ "summary": values.get("summary", ""),
156
+ "message": values.get("message", ""),
157
+ "extra_fields": extra_fields or None,
158
+ "metadata": metadata,
159
+ "timestamp": timestamp or datetime.now(UTC).isoformat(),
160
+ }
@@ -0,0 +1,137 @@
1
+ """SQLite-backed local storage for feedback entries.
2
+
3
+ Stores every submission locally before attempting GitHub sync,
4
+ so nothing is lost if the network call fails.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import sqlite3
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ _SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS feedback (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ timestamp TEXT NOT NULL,
21
+ app TEXT,
22
+ version TEXT,
23
+ platform TEXT,
24
+ category TEXT,
25
+ name TEXT,
26
+ email TEXT,
27
+ summary TEXT,
28
+ message TEXT NOT NULL,
29
+ extra_fields_json TEXT,
30
+ metadata_json TEXT,
31
+ github_issue_number INTEGER,
32
+ github_issue_url TEXT,
33
+ github_sync_status TEXT,
34
+ github_sync_error TEXT,
35
+ github_synced_at TEXT
36
+ )
37
+ """
38
+
39
+
40
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
41
+ conn.execute(_SCHEMA)
42
+ existing = {row[1] for row in conn.execute("PRAGMA table_info(feedback)").fetchall()}
43
+ additions = {
44
+ "app": "TEXT",
45
+ "version": "TEXT",
46
+ "platform": "TEXT",
47
+ "summary": "TEXT",
48
+ "extra_fields_json": "TEXT",
49
+ "metadata_json": "TEXT",
50
+ "github_issue_number": "INTEGER",
51
+ "github_issue_url": "TEXT",
52
+ "github_sync_status": "TEXT",
53
+ "github_sync_error": "TEXT",
54
+ "github_synced_at": "TEXT",
55
+ }
56
+ for col, col_type in additions.items():
57
+ if col not in existing:
58
+ conn.execute(f"ALTER TABLE feedback ADD COLUMN {col} {col_type}")
59
+ conn.commit()
60
+
61
+
62
+ def open_db(path: Path) -> sqlite3.Connection:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ conn = sqlite3.connect(str(path))
65
+ conn.execute("PRAGMA journal_mode=WAL")
66
+ _ensure_schema(conn)
67
+ return conn
68
+
69
+
70
+ def save(entry: dict[str, object], db_path: Path) -> int:
71
+ """Persist entry and return its row id."""
72
+ extra = entry.get("extra_fields")
73
+ metadata = entry.get("metadata")
74
+ conn = open_db(db_path)
75
+ cur = conn.execute(
76
+ """INSERT INTO feedback
77
+ (timestamp, app, version, platform, category, name, email,
78
+ summary, message, extra_fields_json, metadata_json)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
80
+ (
81
+ str(entry.get("timestamp", datetime.now(UTC).isoformat())),
82
+ str(entry.get("app", "")),
83
+ str(entry.get("version", "")),
84
+ str(entry.get("platform", "")),
85
+ str(entry.get("category", "feedback")),
86
+ str(entry.get("name", "")),
87
+ str(entry.get("email", "")),
88
+ str(entry.get("summary", "")),
89
+ str(entry.get("message", "")),
90
+ json.dumps(extra, sort_keys=True) if isinstance(extra, dict) else "",
91
+ json.dumps(metadata, sort_keys=True) if isinstance(metadata, dict) else "",
92
+ ),
93
+ )
94
+ row_id = int(cur.lastrowid)
95
+ conn.commit()
96
+ conn.close()
97
+ return row_id
98
+
99
+
100
+ def update_github_sync(
101
+ row_id: int,
102
+ *,
103
+ issue_number: Optional[int],
104
+ issue_url: Optional[str],
105
+ error: Optional[str],
106
+ db_path: Path,
107
+ ) -> None:
108
+ status = "synced" if issue_url else "failed"
109
+ conn = open_db(db_path)
110
+ conn.execute(
111
+ """UPDATE feedback
112
+ SET github_issue_number=?, github_issue_url=?,
113
+ github_sync_status=?, github_sync_error=?,
114
+ github_synced_at=?
115
+ WHERE id=?""",
116
+ (
117
+ issue_number,
118
+ issue_url,
119
+ status,
120
+ error,
121
+ datetime.now(UTC).isoformat(),
122
+ row_id,
123
+ ),
124
+ )
125
+ conn.commit()
126
+ conn.close()
127
+
128
+
129
+ def list_all(db_path: Path) -> list[dict]:
130
+ if not db_path.exists():
131
+ return []
132
+ conn = open_db(db_path)
133
+ cur = conn.execute("SELECT * FROM feedback ORDER BY id DESC")
134
+ cols = [d[0] for d in cur.description]
135
+ rows = [dict(zip(cols, row)) for row in cur.fetchall()]
136
+ conn.close()
137
+ return rows
@@ -0,0 +1,91 @@
1
+ """GLOW backward-compatibility shim.
2
+
3
+ Replace the entire content of
4
+ ``web/src/acb_large_print_web/routes/feedback.py`` with::
5
+
6
+ from feedback_hub.compat_glow import feedback_bp
7
+
8
+ That is the only change needed. All route names are preserved:
9
+ feedback.feedback_form
10
+ feedback.feedback_submit
11
+ feedback.feedback_submit_api
12
+ feedback.feedback_review
13
+
14
+ The GLOW app also imports support_hub.create_support_issue and
15
+ support_hub.load_support_hub_config in a few places. Those still work via::
16
+
17
+ from feedback_hub.compat_glow import create_support_issue, load_support_hub_config
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+
23
+ from feedback_hub._github import GitHubConfig, create_issue, resolve_token
24
+ from feedback_hub.flask_blueprint import make_blueprint
25
+
26
+
27
+ def _glow_version() -> str:
28
+ try:
29
+ from acb_large_print_web.version import get_version
30
+ return get_version()
31
+ except Exception:
32
+ return ""
33
+
34
+
35
+ feedback_bp = make_blueprint(
36
+ app_name="GLOW",
37
+ github_repo=os.environ.get("FEEDBACK_GITHUB_REPO", "Community-Access/support"),
38
+ github_labels=[
39
+ l.strip()
40
+ for l in os.environ.get("FEEDBACK_GITHUB_LABELS", "needs-triage").split(",")
41
+ if l.strip()
42
+ ],
43
+ github_assignee=os.environ.get("FEEDBACK_GITHUB_ASSIGNEE", ""),
44
+ app_version_fn=_glow_version,
45
+ blueprint_name="feedback",
46
+ extra_fields=[
47
+ {"name": "task", "label": "What were you trying to do", "type": "text", "max_length": 240},
48
+ ],
49
+ )
50
+
51
+
52
+ # Legacy API preserved for any GLOW code that imports from support_hub directly
53
+ def load_support_hub_config():
54
+ """Backward-compatible stub returning a simple namespace."""
55
+ class _Cfg:
56
+ token = resolve_token()
57
+ repo = os.environ.get("FEEDBACK_GITHUB_REPO", "Community-Access/support")
58
+ assignee = os.environ.get("FEEDBACK_GITHUB_ASSIGNEE", "")
59
+ labels = [
60
+ l.strip()
61
+ for l in os.environ.get("FEEDBACK_GITHUB_LABELS", "needs-triage").split(",")
62
+ if l.strip()
63
+ ]
64
+ api_token = os.environ.get("FEEDBACK_API_TOKEN", "").strip()
65
+ return _Cfg()
66
+
67
+
68
+ def create_support_issue(entry: dict) -> tuple:
69
+ """Backward-compatible wrapper over the new create_issue."""
70
+ cfg_raw = load_support_hub_config()
71
+ cfg = GitHubConfig(
72
+ token=cfg_raw.token,
73
+ repo=cfg_raw.repo,
74
+ assignee=cfg_raw.assignee,
75
+ labels=cfg_raw.labels,
76
+ )
77
+ # Map old GLOW entry keys to new format
78
+ mapped = {
79
+ "app": entry.get("source_app", "GLOW"),
80
+ "version": entry.get("source_version", ""),
81
+ "platform": entry.get("platform", ""),
82
+ "category": entry.get("category", "feedback"),
83
+ "name": entry.get("name", ""),
84
+ "email": entry.get("email", ""),
85
+ "summary": entry.get("summary", ""),
86
+ "message": entry.get("message", ""),
87
+ "timestamp": entry.get("timestamp", ""),
88
+ "extra_fields": {"Task": entry.get("task", "")} if entry.get("task") else None,
89
+ }
90
+ number, url, error = create_issue(mapped, cfg)
91
+ return number, url, error
@@ -0,0 +1,267 @@
1
+ """Flask Blueprint adapter.
2
+
3
+ Drop-in replacement for GLOW's feedback.py routes. Registers a blueprint
4
+ that serves:
5
+ GET / -- feedback form (HTML)
6
+ POST / -- form submission
7
+ POST /api -- JSON submission (Bearer token auth)
8
+ GET /review -- protected review dashboard
9
+
10
+ GLOW migration (non-breaking)::
11
+
12
+ # web/src/acb_large_print_web/routes/feedback.py
13
+ # Replace the entire file with:
14
+ from feedback_hub.flask_blueprint import make_blueprint
15
+ feedback_bp = make_blueprint(
16
+ app_name="GLOW",
17
+ github_repo="Community-Access/support",
18
+ app_version_fn=lambda: get_version(),
19
+ )
20
+
21
+ All existing route names (feedback.feedback_form, feedback.feedback_submit,
22
+ feedback.feedback_submit_api, feedback.feedback_review) are preserved.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import hmac
27
+ import json
28
+ import logging
29
+ import os
30
+ import platform
31
+ import sys
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+ from typing import Callable, Optional
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ def make_blueprint(
40
+ *,
41
+ app_name: str,
42
+ github_repo: str,
43
+ github_labels: Optional[list[str]] = None,
44
+ github_assignee: str = "",
45
+ github_token: str = "",
46
+ app_version_fn: Optional[Callable[[], str]] = None,
47
+ db_path: Optional[Path] = None,
48
+ url_prefix: str = "/feedback",
49
+ blueprint_name: str = "feedback",
50
+ categories: Optional[list[str]] = None,
51
+ extra_fields: Optional[list[dict]] = None,
52
+ ):
53
+ """Create and return a configured Flask Blueprint.
54
+
55
+ Parameters
56
+ ----------
57
+ app_name:
58
+ Human name shown on the form and in GitHub issue titles.
59
+ github_repo:
60
+ ``owner/repo`` slug (e.g. ``BITS-ACB/chapterforge``).
61
+ github_token:
62
+ Optional token override. Falls back to env vars automatically.
63
+ app_version_fn:
64
+ Callable returning the current app version string.
65
+ db_path:
66
+ SQLite database path. Defaults to Flask's ``instance/feedback.db``.
67
+ categories:
68
+ Issue category choices shown in the form drop-down.
69
+ extra_fields:
70
+ List of ``{"name": ..., "label": ..., "type": ..., "required": ...}``
71
+ dicts for app-specific fields beyond the defaults.
72
+ """
73
+ from flask import (
74
+ Blueprint,
75
+ abort,
76
+ current_app,
77
+ jsonify,
78
+ render_template,
79
+ request,
80
+ )
81
+
82
+ from feedback_hub._github import GitHubConfig, create_issue, resolve_token
83
+ from feedback_hub._schema import AppSchema, FieldSchema, build_entry, load_schema
84
+ from feedback_hub._storage import (
85
+ save as _save,
86
+ update_github_sync as _update_sync,
87
+ )
88
+
89
+ bp = Blueprint(blueprint_name, __name__, template_folder="templates")
90
+
91
+ _categories = categories or [
92
+ "Bug Report",
93
+ "Feature Request",
94
+ "Accessibility Issue",
95
+ "Documentation",
96
+ "Other",
97
+ ]
98
+ _schema = AppSchema(
99
+ app=app_name,
100
+ github_repo=github_repo,
101
+ github_labels=github_labels or ["needs-triage"],
102
+ github_assignee=github_assignee,
103
+ categories=_categories,
104
+ fields=[
105
+ FieldSchema("summary", "Summary", "text", required=True, max_length=120),
106
+ FieldSchema("message", "Details", "textarea", required=True, max_length=5000),
107
+ *(
108
+ FieldSchema(
109
+ f["name"],
110
+ f.get("label", f["name"].title()),
111
+ f.get("type", "text"),
112
+ required=f.get("required", False),
113
+ max_length=f.get("max_length", 0),
114
+ options=f.get("options", []),
115
+ default=f.get("default", ""),
116
+ )
117
+ for f in (extra_fields or [])
118
+ ),
119
+ ],
120
+ )
121
+
122
+ def _resolved_token() -> str:
123
+ return resolve_token(
124
+ github_token,
125
+ os.environ.get("SUPPORT_HUB_GITHUB_TOKEN", ""),
126
+ os.environ.get("FEEDBACK_GITHUB_TOKEN", ""),
127
+ )
128
+
129
+ def _get_db() -> Path:
130
+ if db_path:
131
+ return db_path
132
+ try:
133
+ return Path(current_app.instance_path) / "feedback.db"
134
+ except RuntimeError:
135
+ return Path("instance") / "feedback.db"
136
+
137
+ def _get_version() -> str:
138
+ if app_version_fn:
139
+ try:
140
+ return app_version_fn()
141
+ except Exception:
142
+ pass
143
+ return ""
144
+
145
+ def _submit(entry: dict) -> tuple[Optional[str], Optional[str]]:
146
+ """Store locally then sync to GitHub. Returns (issue_url, error)."""
147
+ try:
148
+ row_id = _save(entry, _get_db())
149
+ except Exception as exc:
150
+ log.exception("Failed to save feedback to SQLite")
151
+ return None, str(exc)
152
+
153
+ token = _resolved_token()
154
+ if not token:
155
+ return None, "GitHub token not configured"
156
+
157
+ cfg = GitHubConfig(
158
+ token=token,
159
+ repo=_schema.github_repo,
160
+ assignee=_schema.github_assignee,
161
+ labels=_schema.github_labels,
162
+ )
163
+ number, url, error = create_issue(entry, cfg)
164
+ try:
165
+ _update_sync(row_id, issue_number=number, issue_url=url, error=error, db_path=_get_db())
166
+ except Exception:
167
+ pass
168
+ return url, error
169
+
170
+ # ------------------------------------------------------------------
171
+ # Routes
172
+ # ------------------------------------------------------------------
173
+
174
+ @bp.route("/", methods=["GET"])
175
+ def feedback_form():
176
+ return render_template(
177
+ "feedback_hub/form.html",
178
+ schema=_schema,
179
+ error="",
180
+ values={},
181
+ )
182
+
183
+ @bp.route("/", methods=["POST"])
184
+ def feedback_submit():
185
+ values = {
186
+ f.name: request.form.get(f.name, "").strip()
187
+ for f in _schema.fields
188
+ }
189
+ errors = _schema.validate(values)
190
+ if errors:
191
+ return render_template(
192
+ "feedback_hub/form.html",
193
+ schema=_schema,
194
+ error=" ".join(errors),
195
+ values=values,
196
+ ), 400
197
+
198
+ entry = build_entry(
199
+ _schema,
200
+ values,
201
+ name=request.form.get("name", "").strip(),
202
+ email=request.form.get("email", "").strip(),
203
+ category=request.form.get("category", _categories[0]),
204
+ app_version=_get_version(),
205
+ metadata={
206
+ "source_channel": "web",
207
+ "user_agent": request.headers.get("User-Agent", ""),
208
+ },
209
+ )
210
+ issue_url, _error = _submit(entry)
211
+ return render_template(
212
+ "feedback_hub/thanks.html",
213
+ schema=_schema,
214
+ issue_url=issue_url,
215
+ )
216
+
217
+ @bp.route("/api", methods=["POST"])
218
+ def feedback_submit_api():
219
+ api_token = os.environ.get("FEEDBACK_HUB_API_TOKEN", "").strip() or \
220
+ os.environ.get("FEEDBACK_API_TOKEN", "").strip()
221
+ if not api_token:
222
+ abort(404)
223
+ auth = request.headers.get("Authorization", "")
224
+ if not auth or not hmac.compare_digest(auth, f"Bearer {api_token}"):
225
+ return jsonify({"error": "Unauthorized"}), 403
226
+
227
+ payload = request.get_json(silent=True)
228
+ if not isinstance(payload, dict):
229
+ return jsonify({"error": "JSON object required"}), 400
230
+
231
+ values = {f.name: str(payload.get(f.name, "")).strip() for f in _schema.fields}
232
+ errors = _schema.validate(values)
233
+ if errors:
234
+ return jsonify({"error": " ".join(errors)}), 400
235
+
236
+ entry = build_entry(
237
+ _schema,
238
+ values,
239
+ name=str(payload.get("name", "")),
240
+ email=str(payload.get("email", "")),
241
+ category=str(payload.get("category", _categories[0])),
242
+ app_version=str(payload.get("version", _get_version())),
243
+ metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None,
244
+ )
245
+ issue_url, sync_error = _submit(entry)
246
+ return jsonify({
247
+ "status": "accepted",
248
+ "issue_url": issue_url,
249
+ "github_sync_status": "synced" if issue_url else "failed",
250
+ "github_sync_error": sync_error,
251
+ }), 202
252
+
253
+ @bp.route("/review")
254
+ def feedback_review():
255
+ from feedback_hub._storage import list_all
256
+
257
+ password = os.environ.get("FEEDBACK_PASSWORD", "").strip()
258
+ if not password:
259
+ abort(404)
260
+ provided = request.args.get("key", "")
261
+ if not provided or not hmac.compare_digest(provided, password):
262
+ abort(403)
263
+
264
+ rows = list_all(_get_db())
265
+ return render_template("feedback_hub/review.html", schema=_schema, rows=rows)
266
+
267
+ return bp
@@ -0,0 +1,246 @@
1
+ """wxPython native feedback dialog.
2
+
3
+ Usage (ChapterForge, QUILL, any wxPython app)::
4
+
5
+ from feedback_hub.wx_dialog import FeedbackDialog
6
+ from feedback_hub import AppSchema, load_schema
7
+
8
+ schema = load_schema(Path("schemas/chapterforge.json"))
9
+ dlg = FeedbackDialog(parent, schema=schema, github_token=TOKEN)
10
+ dlg.ShowModal()
11
+ dlg.Destroy()
12
+
13
+ The dialog submits directly to GitHub Issues -- no browser, no clipboard.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import platform
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ from feedback_hub._github import GitHubConfig, create_issue, resolve_token
23
+ from feedback_hub._schema import AppSchema, build_entry
24
+ from feedback_hub._storage import save as save_entry, update_github_sync
25
+
26
+
27
+ class FeedbackDialog:
28
+ """Accessible wxPython dialog that submits issues directly to GitHub.
29
+
30
+ Requires wxPython to be importable. It is imported lazily so the
31
+ rest of feedback_hub works in environments without wx (e.g. GLOW).
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ parent: Any,
37
+ *,
38
+ schema: AppSchema,
39
+ github_token: str = "",
40
+ db_path: Optional[Path] = None,
41
+ app_version: str = "",
42
+ ) -> None:
43
+ import wx
44
+
45
+ self._wx = wx
46
+ self._schema = schema
47
+ self._app_version = app_version or schema.version
48
+ self._db_path = db_path or (
49
+ Path.home() / "AppData" / "Roaming" / schema.app / "feedback.db"
50
+ if sys.platform == "win32"
51
+ else Path.home() / ".local" / "share" / schema.app.lower() / "feedback.db"
52
+ )
53
+ self._token = resolve_token(github_token)
54
+ self._dialog = wx.Dialog(
55
+ parent,
56
+ title=f"Report an Issue - {schema.app}",
57
+ size=(720, 680),
58
+ )
59
+ self._field_controls: dict[str, Any] = {}
60
+ self._build_ui()
61
+
62
+ # ------------------------------------------------------------------
63
+ # Public
64
+ # ------------------------------------------------------------------
65
+
66
+ def ShowModal(self) -> int:
67
+ return self._dialog.ShowModal()
68
+
69
+ def Destroy(self) -> None:
70
+ self._dialog.Destroy()
71
+
72
+ # ------------------------------------------------------------------
73
+ # Internal
74
+ # ------------------------------------------------------------------
75
+
76
+ def _build_ui(self) -> None:
77
+ wx = self._wx
78
+ schema = self._schema
79
+ dlg = self._dialog
80
+ panel = wx.Panel(dlg)
81
+ root = wx.BoxSizer(wx.VERTICAL)
82
+
83
+ # Instruction text
84
+ intro = wx.StaticText(
85
+ panel,
86
+ label=(
87
+ f"Describe your issue and it will be submitted directly to the "
88
+ f"{schema.app} issue tracker. No account required."
89
+ ),
90
+ )
91
+ intro.Wrap(660)
92
+ root.Add(intro, 0, wx.ALL | wx.EXPAND, 8)
93
+
94
+ # Name / email row
95
+ name_row = wx.BoxSizer(wx.HORIZONTAL)
96
+ name_label = wx.StaticText(panel, label="Your name (optional)")
97
+ self._name_ctrl = wx.TextCtrl(panel)
98
+ self._name_ctrl.SetName("Your name")
99
+ email_label = wx.StaticText(panel, label="Email (optional)")
100
+ self._email_ctrl = wx.TextCtrl(panel)
101
+ self._email_ctrl.SetName("Email address")
102
+ name_row.Add(name_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
103
+ name_row.Add(self._name_ctrl, 1, wx.RIGHT, 16)
104
+ name_row.Add(email_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
105
+ name_row.Add(self._email_ctrl, 1)
106
+ root.Add(name_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
107
+
108
+ # Category
109
+ cat_label = wx.StaticText(panel, label="Category")
110
+ self._cat_ctrl = wx.Choice(panel, choices=schema.categories)
111
+ self._cat_ctrl.SetName("Issue category")
112
+ self._cat_ctrl.SetSelection(0)
113
+ root.Add(cat_label, 0, wx.LEFT | wx.RIGHT | wx.TOP, 8)
114
+ root.Add(self._cat_ctrl, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
115
+
116
+ # Schema-driven fields
117
+ defaults = schema.resolve_defaults(self._app_version)
118
+ for f in schema.fields:
119
+ label = wx.StaticText(panel, label=f.label + (" *" if f.required else ""))
120
+ root.Add(label, 0, wx.LEFT | wx.RIGHT | wx.TOP, 8)
121
+ if f.type == "textarea":
122
+ ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE, size=(-1, 90))
123
+ elif f.type == "select" and f.options:
124
+ ctrl = wx.Choice(panel, choices=f.options)
125
+ else:
126
+ ctrl = wx.TextCtrl(panel)
127
+
128
+ ctrl.SetName(f.label)
129
+ if isinstance(ctrl, wx.TextCtrl) and defaults.get(f.name):
130
+ ctrl.SetValue(defaults[f.name])
131
+ if f.placeholder and isinstance(ctrl, wx.TextCtrl):
132
+ ctrl.SetHint(f.placeholder)
133
+ root.Add(ctrl, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
134
+ self._field_controls[f.name] = ctrl
135
+
136
+ # Buttons
137
+ buttons = wx.StdDialogButtonSizer()
138
+ self._submit_btn = wx.Button(dlg, wx.ID_OK, label="Submit Issue")
139
+ cancel_btn = wx.Button(dlg, wx.ID_CANCEL, label="Cancel")
140
+ buttons.AddButton(self._submit_btn)
141
+ buttons.AddButton(cancel_btn)
142
+ buttons.Realize()
143
+ root.Add(buttons, 0, wx.ALL | wx.EXPAND, 8)
144
+
145
+ panel.SetSizer(root)
146
+ panel.Layout()
147
+
148
+ self._submit_btn.Bind(wx.EVT_BUTTON, self._on_submit)
149
+ self._name_ctrl.SetFocus()
150
+
151
+ def _on_submit(self, _event: Any) -> None:
152
+ wx = self._wx
153
+ values = {}
154
+ for name, ctrl in self._field_controls.items():
155
+ if isinstance(ctrl, wx.Choice):
156
+ sel = ctrl.GetSelection()
157
+ values[name] = ctrl.GetString(sel) if sel != wx.NOT_FOUND else ""
158
+ else:
159
+ values[name] = ctrl.GetValue().strip()
160
+
161
+ cat_sel = self._cat_ctrl.GetSelection()
162
+ category = (
163
+ self._cat_ctrl.GetString(cat_sel)
164
+ if cat_sel != wx.NOT_FOUND
165
+ else self._schema.categories[0]
166
+ )
167
+
168
+ errors = self._schema.validate(values)
169
+ if errors:
170
+ wx.MessageBox(
171
+ "\n".join(errors),
172
+ f"{self._schema.app} - Validation Error",
173
+ wx.OK | wx.ICON_WARNING,
174
+ self._dialog,
175
+ )
176
+ return
177
+
178
+ entry = build_entry(
179
+ self._schema,
180
+ values,
181
+ name=self._name_ctrl.GetValue().strip(),
182
+ email=self._email_ctrl.GetValue().strip(),
183
+ category=category,
184
+ app_version=self._app_version,
185
+ metadata={
186
+ "python": sys.version.splitlines()[0],
187
+ "platform": platform.platform(),
188
+ },
189
+ )
190
+
191
+ # Store locally first
192
+ try:
193
+ row_id = save_entry(entry, self._db_path)
194
+ except Exception:
195
+ row_id = None
196
+
197
+ # Submit to GitHub
198
+ if not self._token:
199
+ wx.MessageBox(
200
+ "Your report was saved locally but could not be submitted to GitHub - "
201
+ "no token is configured.",
202
+ f"{self._schema.app} - Saved Locally",
203
+ wx.OK | wx.ICON_INFORMATION,
204
+ self._dialog,
205
+ )
206
+ self._dialog.EndModal(wx.ID_OK)
207
+ return
208
+
209
+ cfg = GitHubConfig(
210
+ token=self._token,
211
+ repo=self._schema.github_repo,
212
+ assignee=self._schema.github_assignee,
213
+ labels=self._schema.github_labels,
214
+ )
215
+ issue_number, issue_url, error = create_issue(entry, cfg)
216
+
217
+ if row_id is not None:
218
+ try:
219
+ update_github_sync(
220
+ row_id,
221
+ issue_number=issue_number,
222
+ issue_url=issue_url,
223
+ error=error,
224
+ db_path=self._db_path,
225
+ )
226
+ except Exception:
227
+ pass
228
+
229
+ if issue_url:
230
+ wx.MessageBox(
231
+ f"Your report has been submitted successfully.\n\nIssue: {issue_url}",
232
+ f"{self._schema.app} - Thank You",
233
+ wx.OK | wx.ICON_INFORMATION,
234
+ self._dialog,
235
+ )
236
+ else:
237
+ wx.MessageBox(
238
+ "Your report was saved but could not be submitted to GitHub right now.\n"
239
+ "We will retry automatically on next launch.\n\n"
240
+ f"Error: {error}",
241
+ f"{self._schema.app} - Saved",
242
+ wx.OK | wx.ICON_WARNING,
243
+ self._dialog,
244
+ )
245
+
246
+ self._dialog.EndModal(wx.ID_OK)
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: feedback-hub
3
+ Version: 1.0.0
4
+ Summary: Multi-framework GitHub issue submission library - native UI per framework, centralized GitHub backend
5
+ Project-URL: Homepage, https://github.com/community-access/feedback-hub
6
+ Project-URL: Documentation, https://community-access.github.io/feedback-hub
7
+ Project-URL: Repository, https://github.com/community-access/feedback-hub
8
+ Project-URL: Issues, https://github.com/community-access/feedback-hub/issues
9
+ Author-email: "Blind Information Technology Solutions (BITS)" <info@chapterforge.org>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Blind Information Technology Solutions (BITS)
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Requires-Python: >=3.10
33
+ Provides-Extra: dev
34
+ Requires-Dist: flask>=3.0; extra == 'dev'
35
+ Requires-Dist: hatch; extra == 'dev'
36
+ Requires-Dist: pytest-cov; extra == 'dev'
37
+ Requires-Dist: pytest>=8.0; extra == 'dev'
38
+ Provides-Extra: flask
39
+ Requires-Dist: flask>=3.0; extra == 'flask'
40
+ Provides-Extra: wx
41
+ Requires-Dist: wxpython>=4.2; extra == 'wx'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # feedback-hub
45
+
46
+ Multi-framework GitHub issue submission library. Native UI per framework, centralized GitHub backend.
47
+
48
+ - **wxPython apps** (ChapterForge, QUILL): native dialog, direct GitHub submission
49
+ - **Flask apps** (GLOW): web form, direct GitHub submission
50
+ - **CLI / headless**: function call, direct GitHub submission
51
+
52
+ See [INTEGRATING.md](INTEGRATING.md) for integration guides.
@@ -0,0 +1,11 @@
1
+ feedback_hub/__init__.py,sha256=r6efyMZeNNVHyxX_15Lv-irdvy9adOjlrwtVPAnDpdo,3040
2
+ feedback_hub/_github.py,sha256=Utn8kvEkbE1YDDBg-7gwyFyQR8GbRBUD50SY0Vpc7qs,4839
3
+ feedback_hub/_schema.py,sha256=xGVeifv8wT_8GUyJTGbIqghpiv2iXgeSV791x3EEtvU,5708
4
+ feedback_hub/_storage.py,sha256=-Ur0ncU8MPHNy2ReDyQfYjEC4AQbKgkWExerCcEoals,4104
5
+ feedback_hub/compat_glow.py,sha256=ye4vSxbY8sROP17RGN24vYI_K9D7uln3sdE9pvxt_28,3077
6
+ feedback_hub/flask_blueprint.py,sha256=iWNRBKXB2J3sey5p-E0dehhurPFJAp_GgzKjHb-KIGc,8744
7
+ feedback_hub/wx_dialog.py,sha256=DcLpcVxfROm-9-L8E2CMuV4FEtD-wMI8tBWmvAJN3f8,8605
8
+ feedback_hub-1.0.0.dist-info/METADATA,sha256=hTf1ctrP1rD3FWsWpcs9XS-dParV_sDCwB-n8zIkMVk,2609
9
+ feedback_hub-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ feedback_hub-1.0.0.dist-info/licenses/LICENSE,sha256=aW5a-ckzPcVmKQLFJIiCDUJ71vO6IA5VUG9jOT_inKM,1102
11
+ feedback_hub-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blind Information Technology Solutions (BITS)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.