diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d7a148f2..628e4a97f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,15 +98,15 @@ jobs: aqt_dependency_group: aqt qt_package: pyqt6 - - name: Enable cloning web app repo + - name: Enable cloning webapp repo uses: webfactory/ssh-agent@v0.5.4 with: ssh-private-key: ${{ secrets.ANKIHUB_SSH_PRIVATE_KEY }} - - name: Checkout ankipalace/ankihub web app repo + - name: Checkout AnkiHubSoftware/ankihub web app repo uses: actions/checkout@v4 with: - repository: ankipalace/ankihub + repository: AnkiHubSoftware/ankihub ref: dev path: ankihub_web diff --git a/ankihub/ankihub_client/ankihub_client.py b/ankihub/ankihub_client/ankihub_client.py index 49c8b002b..e0ec7225f 100644 --- a/ankihub/ankihub_client/ankihub_client.py +++ b/ankihub/ankihub_client/ankihub_client.py @@ -134,6 +134,44 @@ def _should_retry_for_response(response: Response) -> bool: RETRY_CONDITION = retry_if_result(_should_retry_for_response) | retry_if_exception_type(REQUEST_RETRY_EXCEPTION_TYPES) +def _sanitized_response_detail(response: Response) -> Optional[str]: + """Extract a safe, truncated detail string from an HTTP response body. + + Prefers structured JSON fields ("detail", "message") when available, + otherwise falls back to a truncated version of the raw body text. + Returns None if the body cannot be read at all. + """ + try: + content_type = (response.headers.get("Content-Type") or "").lower() + except Exception: + content_type = "" + + try: + body_text = response.text + except Exception: + return None + + if not body_text: + return None + + # Prefer structured error information from JSON responses. + if "application/json" in content_type: + try: + data = response.json() + if isinstance(data, dict): + detail = data.get("detail") or data.get("message") + if isinstance(detail, str): + return detail + except Exception: + pass + + # Fall back to a truncated version of the body. + max_len = 500 + if len(body_text) > max_len: + return body_text[:max_len] + "... [truncated]" + return body_text + + class AnkiHubHTTPError(Exception): """An unexpected HTTP code was returned in response to a request by the AnkiHub client.""" @@ -141,7 +179,13 @@ def __init__(self, response: Response): self.response = response def __str__(self): - return f"AnkiHub request error: {self.response.status_code} {self.response.reason}" + summary = f"AnkiHub request error: {self.response.status_code} {self.response.reason}" + detail = _sanitized_response_detail(self.response) + if detail is None: + return f"{summary}\nUnable to read response content" + if detail: + return f"{summary}\n{detail}" + return summary class AnkiHubRequestException(Exception): diff --git a/ankihub/entry_point.py b/ankihub/entry_point.py index e3ad273d4..785a780cf 100644 --- a/ankihub/entry_point.py +++ b/ankihub/entry_point.py @@ -252,7 +252,7 @@ def _general_setup() -> None: _trigger_addon_update_check() LOGGER.info("Triggered add-on update check.") - from . import media_export # noqa: F401 + from . import media_export # type: ignore[attr-defined] # noqa: F401 LOGGER.info("Loaded media_export.") diff --git a/ankihub/gui/tutorial.py b/ankihub/gui/tutorial.py index 3c7967730..4850e7d28 100644 --- a/ankihub/gui/tutorial.py +++ b/ankihub/gui/tutorial.py @@ -8,7 +8,7 @@ import aqt from aqt import gui_hooks from aqt.editor import Editor -from aqt.main import AnkiQt +from aqt.main import AnkiQt, MainWindowState from aqt.overview import Overview, OverviewBottomBar from aqt.qt import ( QPoint, @@ -256,7 +256,7 @@ class TutorialStep: hidden_callback: Optional[Callable[[], None]] = None next_callback: Optional[Callable[[Callable[[], None]], None]] = None next_label: str = "Next" - back_callback: Optional[Callable[[], None]] = None + back_callback: Optional[Callable[[Callable[[], None]], None]] = None back_label: str = "Back" block_target_click: bool = False auto_advance: bool = True @@ -526,10 +526,10 @@ def next(self) -> None: self.show_current() -def ensure_mw_state(state: str) -> Callable[[...], None]: - def change_state_and_call_func(func: Callable[[...], None], *args: Any, **kwargs: Any) -> None: - from aqt.main import MainWindowState - +def ensure_mw_state( + state: MainWindowState, +) -> Callable[[Callable[..., None]], Callable[..., None]]: + def change_state_and_call_func(func: Callable[..., None], *args: Any, **kwargs: Any) -> None: def on_state_did_change(old_state: MainWindowState, new_state: MainWindowState) -> None: gui_hooks.state_did_change.remove(on_state_did_change) # Some delay appears to be required for the toolbar @@ -538,7 +538,7 @@ def on_state_did_change(old_state: MainWindowState, new_state: MainWindowState) gui_hooks.state_did_change.append(on_state_did_change) aqt.mw.moveToState(state) - def decorated_func(func: Callable[[...], None]) -> Callable[[...], None]: + def decorated_func(func: Callable[..., None]) -> Callable[..., None]: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> None: if aqt.mw.state != state: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d6c0f2934..0d321664c 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -116,7 +116,32 @@ def client_with_server_setup(vcr: VCR, marks: List[str], request: FixtureRequest if not is_playback_mode(vcr, request): create_db_dump_if_not_exists() - # Restore DB from dump + # Restore DB from dump using a filtered TOC list that excludes the + # SCHEMA entry. pg_restore --clean tries to DROP SCHEMA public which + # fails when extensions (pg_trgm, vector) depend on it. By filtering + # out the SCHEMA entry we still get full --clean behavior for tables + # (drop + recreate) without touching the schema itself. + toc_list_path = "/tmp/restore_toc.list" + result = subprocess.run( + [ + "docker", + "exec", + "-i", + DB_CONTAINER_NAME, + "bash", + "-c", + (f"set -o pipefail; pg_restore -l {DB_DUMP_FILE_NAME} | grep -v ' SCHEMA ' > {toc_list_path}"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + report_command_result( + command_name="pg_restore TOC list", + result=result, + raise_on_error=True, + ) + result = subprocess.run( [ "docker", @@ -128,6 +153,9 @@ def client_with_server_setup(vcr: VCR, marks: List[str], request: FixtureRequest f"--username={DB_USERNAME}", "--format=custom", "--clean", + "--if-exists", + "-L", + toc_list_path, "--jobs=4", DB_DUMP_FILE_NAME, ],