From 11c36133abfee3422bccce5eb5a1470063b7ff4d Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Sun, 26 Jul 2026 07:24:33 -0400 Subject: [PATCH] Qt-removal R7.2: relocate the 33 Qt-free survivor modules out of graphlink_app/ Moves everything backend/ has a live runtime dependency on - 17 flat modules (api_provider, graphlink_task_config, graphlink_licensing, graphlink_chart_agent, graphlink_chart_data, graphlink_chart_rendering, graphlink_chat_agent, graphlink_artifact_agent, graphlink_grid_view_settings, graphlink_navigation_pins, graphlink_prompts, graphlink_version, graphlink_audio, graphlink_model_catalog, graphlink_secrets, graphlink_token_estimator, graphlink_memory) plus the graphlink_plugins/ package - to sit as ordinary siblings of backend/ at the repo root, preserving their existing bare-name cross-imports unchanged. backend/__init__.py's sys.path.insert hack (which reached into graphlink_app/ for exactly this reason) is removed entirely - repo root is already on sys.path whenever backend/ itself is importable, so no path manipulation is needed for its new siblings either. content_codec.py stays exactly where it is (graphlink_app/graphlink_session/), NOT moved - graphlink_session/deserializers.py and serializers.py (Qt-tainted, still live) import it via a normal package-relative import that a physical move would break. Its logic is ported into backend/canvas.py instead (the same "reimplement, don't import" precedent backend/chat_library.py and backend/crash_recovery.py already follow), removing the raw importlib.util.spec_from_file_location workaround this replaces. graphlink_plugins/ moved WHOLESALE - all 19 files, including 3 still-Qt-tainted ones (graphlink_plugin_context_menu.py, graphlink_plugin_portal.py, web_research/worker.py). A design pass had proposed leaving those 3 behind to keep the new location Qt-pure; that broke immediately at collection time (ModuleNotFoundError) because Python resolves a dotted import like graphlink_plugins.graphlink_plugin_portal against ONE specific package - whichever directory of that name comes first on sys.path - and two same-named packages can never coexist. graphlink_plugins/ is therefore NOT 100% Qt-free at its new home; that closes at the R7.6 cutover. Two required fixes an earlier design/verification pass had missed entirely (it only checked backend/'s own consumption of these files, not graphlink_app/'s own internal consumption of the files being pulled out from under it): - graphlink_app/tests/conftest.py's sys.path.insert only reached graphlink_app/ itself; now inserts repo root too, or ~30 test files under graphlink_app/tests/ fail collection. - graphlink_app/graphlink_app.py (the legacy entry point) needed its own sys.path insert added BEFORE its first graphlink_* import (line 5, `from graphlink_window import ChatWindow` - not line 7 as first proposed; verified by actually importing the module and watching it fail at the wrong place), since graphlink_window.py itself transitively needs api_provider/graphlink_audio/graphlink_prompts/graphlink_navigation_pins/ graphlink_token_estimator. pyproject.toml: the 17 relocated modules are dropped from py-modules (setuptools' build_py only ever consults package_dir[""] for flat modules, and per-module package_dir overrides are silently ignored for modules - confirmed empirically - so there's no way to keep listing them while they live outside graphlink_app/; a real wheel/sdist build won't bundle them, a known, accepted, currently-inert gap since nothing in this project's pipeline runs `pip install -e .` today). packages.find now searches both graphlink_app/ and repo root, with a package-dir override for graphlink_plugins (package-dir[""] applies to every discovered package regardless of which `where` root found it, so without this a real build fails outright rather than silently mis-packaging - verified via a real `python -m build --wheel`, which now succeeds and includes all 19 graphlink_plugins/ files at their new path). The dynamic version resolution (`attr = "graphlink_version.APP_VERSION"`) does hard-fail a real build now - verified empirically, not guessed - since setuptools' attr: resolution for a bare module name only ever consults package_dir[""], which must stay "graphlink_app" for the ~89 modules still there; documented plainly as a known gap of the same tier, closing at R7.6. Also fixed: 5 backend/tests/*.py files had comments describing the now-removed sys.path mechanism (code was already correct, only the explanation was stale); graphlink_app/tests/test_ui_token_acceptance.py's hardcoded-hex/font allowlist had two entries for files that no longer live in the scanned tree (graphlink_grid_view_settings.py, graphlink_chart_rendering.py) - removed rather than left to fail the "still exists" assertion; CONTRIBUTING.md and the PR template's compileall/pytest instructions and graphlink_plugins/ path reference updated to match. Verification: 2375 pytest (identical count to before - no test lost or duplicated), compileall clean from repo root, qt_burndown.json's pin re-verified unchanged at 152/84/68 (relocating already-Qt-free files changes nothing the gate counts), 1053 vitest/tsc/eslint/build unaffected (no frontend touched). Both real entry points smoke-tested directly: importing graphlink_app/graphlink_app.py as a module (up to but not including main()) resolves every graphlink_* import cleanly, and backend.app.create_app() resolves api_provider/graphlink_task_config/graphlink_plugins.*/etc from their new repo-root location with zero sys.path setup. Co-Authored-By: Claude Opus 5 --- .github/pull_request_template.md | 2 +- CONTRIBUTING.md | 7 +- .../api_provider.py => api_provider.py | 0 backend/__init__.py | 24 ++-- backend/canvas.py | 123 ++++++++++++++---- backend/tests/conftest.py | 10 +- backend/tests/test_agent_layer_qt_free.py | 11 +- backend/tests/test_agents.py | 8 +- backend/tests/test_app_ws.py | 5 +- backend/tests/test_canvas.py | 8 +- backend/tests/test_crash_recovery_notice.py | 5 +- graphlink_app/graphlink_app.py | 13 ++ graphlink_app/tests/conftest.py | 12 +- .../tests/test_ui_token_acceptance.py | 19 --- ...ct_agent.py => graphlink_artifact_agent.py | 0 .../graphlink_audio.py => graphlink_audio.py | 0 ...chart_agent.py => graphlink_chart_agent.py | 0 ...k_chart_data.py => graphlink_chart_data.py | 0 ...ndering.py => graphlink_chart_rendering.py | 0 ...k_chat_agent.py => graphlink_chat_agent.py | 0 ...ings.py => graphlink_grid_view_settings.py | 0 ...ink_licensing.py => graphlink_licensing.py | 0 ...graphlink_memory.py => graphlink_memory.py | 0 ...l_catalog.py => graphlink_model_catalog.py | 0 ...on_pins.py => graphlink_navigation_pins.py | 0 .../__init__.py | 0 .../code_sandbox/__init__.py | 0 .../code_sandbox/domain.py | 0 .../common/__init__.py | 0 .../common/github_client.py | 0 .../common/llm_json.py | 0 .../gitlink/__init__.py | 0 .../gitlink/agent.py | 0 .../gitlink/repository.py | 0 .../graphlink_plugin_context_menu.py | 0 .../graphlink_plugin_portal.py | 0 .../pycoder/__init__.py | 0 .../pycoder/domain.py | 0 .../web_research/__init__.py | 0 .../web_research/domain.py | 0 .../web_research/fetch_policy.py | 0 .../web_research/ports.py | 0 .../web_research/providers.py | 0 .../web_research/service.py | 0 .../web_research/worker.py | 0 ...aphlink_prompts.py => graphlink_prompts.py | 0 ...aphlink_secrets.py => graphlink_secrets.py | 0 ...task_config.py => graphlink_task_config.py | 0 ...timator.py => graphlink_token_estimator.py | 0 ...aphlink_version.py => graphlink_version.py | 0 pyproject.toml | 67 +++++++--- 51 files changed, 203 insertions(+), 111 deletions(-) rename graphlink_app/api_provider.py => api_provider.py (100%) rename graphlink_app/graphlink_artifact_agent.py => graphlink_artifact_agent.py (100%) rename graphlink_app/graphlink_audio.py => graphlink_audio.py (100%) rename graphlink_app/graphlink_chart_agent.py => graphlink_chart_agent.py (100%) rename graphlink_app/graphlink_chart_data.py => graphlink_chart_data.py (100%) rename graphlink_app/graphlink_chart_rendering.py => graphlink_chart_rendering.py (100%) rename graphlink_app/graphlink_chat_agent.py => graphlink_chat_agent.py (100%) rename graphlink_app/graphlink_grid_view_settings.py => graphlink_grid_view_settings.py (100%) rename graphlink_app/graphlink_licensing.py => graphlink_licensing.py (100%) rename graphlink_app/graphlink_memory.py => graphlink_memory.py (100%) rename graphlink_app/graphlink_model_catalog.py => graphlink_model_catalog.py (100%) rename graphlink_app/graphlink_navigation_pins.py => graphlink_navigation_pins.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/code_sandbox/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/code_sandbox/domain.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/common/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/common/github_client.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/common/llm_json.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/gitlink/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/gitlink/agent.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/gitlink/repository.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/graphlink_plugin_context_menu.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/graphlink_plugin_portal.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/pycoder/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/pycoder/domain.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/__init__.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/domain.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/fetch_policy.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/ports.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/providers.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/service.py (100%) rename {graphlink_app/graphlink_plugins => graphlink_plugins}/web_research/worker.py (100%) rename graphlink_app/graphlink_prompts.py => graphlink_prompts.py (100%) rename graphlink_app/graphlink_secrets.py => graphlink_secrets.py (100%) rename graphlink_app/graphlink_task_config.py => graphlink_task_config.py (100%) rename graphlink_app/graphlink_token_estimator.py => graphlink_token_estimator.py (100%) rename graphlink_app/graphlink_version.py => graphlink_version.py (100%) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 994bfd7a..fb36dc1d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ Describe the change in 2-5 sentences. - [ ] App launches - [ ] Relevant workflow was exercised manually -- [ ] `python -m compileall -q graphlink_app` passes +- [ ] `python -m compileall -q .` passes (run from the repo root - matches CI) ## UI Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4a4f01e..7fa06647 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ If you prefer Visual Studio, open `graphlink_app.sln`. - Launch the app from `graphlink_app/`, not from the repo root. - Prefer editing the real implementation modules in: - - `graphlink_app/graphlink_plugins/` + - `graphlink_plugins/` (repo root - relocated out of graphlink_app/ by the Qt-removal plan's R7.2; only its 2 remaining Qt-tainted siblings, graphlink_plugin_context_menu.py/graphlink_plugin_portal.py, and web_research/worker.py stayed behind) - `graphlink_app/graphlink_nodes/` - `graphlink_app/graphlink_canvas/` - `graphlink_app/graphlink_ui_dialogs/` @@ -42,7 +42,7 @@ The standing branch → push → PR → merge process for this repository. It co - **Branch naming:** `agent/` for AI-agent-authored work (Claude Code, Codex, or similar), or a short descriptive kebab-case slug for human-authored branches. Examples from history: `agent/composer-react-qwebengine`, `agent/sota-model-settings`, `codex/navigation-pins-refactor`. - **Scope each branch to one reviewable unit of work** — the same "keep changes focused" rule from Development Rules, applied at the branch level. Large multi-part efforts should land as a sequence of scoped branches and PRs rather than one oversized branch. - **Push the branch to `origin`**, then open a pull request against `main` using `.github/pull_request_template.md`. -- **Run local validation before opening the PR** (see Validation below). There is no automated CI — GitHub Actions and Dependabot were intentionally removed in commit `185143a` — so this local pass is the only gate. +- **Run local validation before opening the PR** (see Validation below). `.github/workflows/ci.yml` also re-runs the same checks on every PR, but treat that as a backstop, not a substitute for running validation locally first. - **Merge strategy: squash-merge into `main`**, then delete the branch. Merged commit titles carry the PR number, e.g. `Refactor navigation pins and interaction surfaces (#17)`. ## Pull Request Expectations @@ -57,10 +57,9 @@ Please include: ## Validation -Run the `pytest` suite from the inner `graphlink_app/` directory, and a compile smoke check: +Run the `pytest` suite from the repo root, and a compile smoke check - this matches CI exactly (`.github/workflows/ci.yml`): ```powershell -cd graphlink_app pytest python -m compileall -q . ``` diff --git a/graphlink_app/api_provider.py b/api_provider.py similarity index 100% rename from graphlink_app/api_provider.py rename to api_provider.py diff --git a/backend/__init__.py b/backend/__init__.py index 05c060d4..7866a406 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -11,18 +11,16 @@ tests/test_no_qt_anywhere.py. """ -import sys -from pathlib import Path - BACKEND_VERSION = "0.1.0" -# The surviving Qt-free domain modules (GridViewSettings, NavigationPinStore, -# payload dataclasses, session layer, ...) live flat inside graphlink_app/ -# and import each other by bare name - the same path convention the Qt entry -# point used. The backend consumes them under that convention until the R7 -# cutover restructures the tree. Qt-free-ness of everything imported from -# there is enforced per-module by test_no_qt_anywhere.py's zero-tolerance -# rule the moment it lands in backend/ imports. -_GRAPHLINK_APP_DIR = str(Path(__file__).resolve().parents[1] / "graphlink_app") -if _GRAPHLINK_APP_DIR not in sys.path: - sys.path.insert(0, _GRAPHLINK_APP_DIR) +# R7.2: the Qt-free domain modules backend/ depends on (api_provider, +# graphlink_task_config, graphlink_licensing, the graphlink_plugins/ package, +# ...) used to live inside graphlink_app/, reached via a sys.path.insert here. +# They now sit as ordinary siblings of this package at the repo root, so no +# path manipulation is needed: whatever already put this package's own parent +# directory on sys.path (running graphlink_desktop.py, or `python -m pytest` +# from repo root) already makes those siblings importable by the exact same +# bare names, with zero changes to their own cross-imports. Qt-free-ness of +# everything imported from there is still enforced per-module by +# test_no_qt_anywhere.py's zero-tolerance rule the moment it lands in +# backend/ imports. diff --git a/backend/canvas.py b/backend/canvas.py index 1f490e4c..743dd78f 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -21,12 +21,13 @@ from __future__ import annotations import base64 -import importlib.util +import binascii import itertools +import logging import math import uuid from dataclasses import dataclass, field -from pathlib import Path +from types import SimpleNamespace from typing import Any from graphlink_chart_data import ChartDataError, canonicalize_chart_data, SUPPORTED_CHART_TYPES @@ -55,33 +56,101 @@ from backend.token_counter import estimate_tokens -def _load_graphlink_content_codec(): - """R6.3: graphlink_app/graphlink_session/content_codec.py is 100% - Qt-free (it imports only base64/binascii/logging - confirmed by direct - read), but the graphlink_session PACKAGE it lives in is not: - graphlink_session/__init__.py eagerly imports ChatSessionManager and - SaveWorkerThread, and workers.py in turn imports PySide6.QtCore - so a - plain `from graphlink_session.content_codec import ...` would run that - __init__.py first (Python always runs a package's __init__.py, even for - a submodule import) and pull Qt into backend/'s import graph. Same - "package wrapper is hazardous, the leaf module itself is not" situation - backend/chat_library.py's own docstring already documents for - graphlink_session.database - but UNLIKE chat_library.py (which - reimplements ChatDatabase's small SQL surface rather than import it), - content_codec.py is loaded here directly via importlib, bypassing the - package's __init__.py entirely, rather than ported/copied - there is - nothing about it worth forking into a second maintained copy.""" - module_path = ( - Path(__file__).resolve().parents[1] - / "graphlink_app" / "graphlink_session" / "content_codec.py" - ) - spec = importlib.util.spec_from_file_location("_graphlink_session_content_codec", module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +# R7.2: ported from graphlink_app/graphlink_session/content_codec.py, not +# imported. content_codec.py itself is 100% Qt-free (base64/binascii/logging +# only), but the graphlink_session PACKAGE it lives in is not - +# graphlink_session/__init__.py eagerly imports ChatSessionManager and +# SaveWorkerThread, and workers.py in turn imports PySide6.QtCore, so a plain +# `from graphlink_session.content_codec import ...` would run that __init__.py +# first (Python always runs a package's __init__.py, even for a submodule +# import) and pull Qt into backend/'s import graph. Before R7.2 this was +# loaded via a raw importlib.util.spec_from_file_location path load, +# bypassing the package's __init__.py - that workaround existed only because +# every OTHER Qt-free survivor could be reached by relocating the physical +# file (matching R7.2's real destination for the 33 modules it did move); this +# one file can't get the same treatment because graphlink_session/ +# deserializers.py and serializers.py - Qt-tainted, still live until the R7.6 +# cutover - import it via a normal package-relative +# `from graphlink_session.content_codec import (...)`, which a physical move +# would break. So the file itself stays exactly where it is, and its logic is +# ported here instead - the same "reimplement, don't import" precedent +# backend/chat_library.py and backend/crash_recovery.py already follow for a +# Qt-tainted wrapper around Qt-free logic. The _content_codec namespace below +# exists purely so every one of the 8 call sites across canvas.py/ +# session_load.py/session_save.py keeps its existing `_content_codec.foo(...)` +# spelling unchanged. + + +def _serialize_history(history): + serialized_history = [] + for message in history or []: + new_message = message.copy() + if "content" in new_message: + new_message["content"] = _process_content_for_serialization(new_message["content"]) + serialized_history.append(new_message) + return serialized_history + + +def _deserialize_history(history): + deserialized_history = [] + for message in history or []: + new_message = message.copy() + if "content" in new_message: + new_message["content"] = _process_content_for_deserialization(new_message["content"]) + deserialized_history.append(new_message) + return deserialized_history + + +def _process_content_for_serialization(content): + """Base64-encode raw image bytes inside multimodal content payloads.""" + if isinstance(content, list): + processed_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "image_bytes" and isinstance(part.get("data"), bytes): + new_part = part.copy() + new_part["data"] = base64.b64encode(part["data"]).decode("utf-8") + processed_parts.append(new_part) + else: + processed_parts.append(part) + return processed_parts + return content + + +def _process_content_for_deserialization(content): + """Decode base64 image payloads back into raw bytes when loading a chat.""" + if isinstance(content, list): + processed_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "image_bytes" and isinstance(part.get("data"), str): + new_part = part.copy() + try: + new_part["data"] = base64.b64decode(part["data"]) + processed_parts.append(new_part) + except (binascii.Error, ValueError): + logging.exception("Failed to decode base64 image data during deserialization.") + processed_parts.append({"type": "text", "text": "[ERROR: Image Data Corrupted]"}) + else: + processed_parts.append(part) + return processed_parts + return content + +def _encode_image_bytes(data): + return base64.b64encode(data).decode("utf-8") -_content_codec = _load_graphlink_content_codec() + +def _decode_image_bytes(data): + return base64.b64decode(data) + + +_content_codec = SimpleNamespace( + serialize_history=_serialize_history, + deserialize_history=_deserialize_history, + process_content_for_serialization=_process_content_for_serialization, + process_content_for_deserialization=_process_content_for_deserialization, + encode_image_bytes=_encode_image_bytes, + decode_image_bytes=_decode_image_bytes, +) # Dark-theme grid swatches. The Qt bridge derived 3 of 5 from the live # QPalette; the backend is Qt-free by law (test_no_qt_anywhere.py), so until diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 911ae358..cf412fc5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,10 +1,10 @@ import pytest -import backend # noqa: F401 - side effect: backend/__init__.py's own import -# puts graphlink_app/ on sys.path - this must come before the bare -# `import api_provider` below, matching the same convention every other -# file in this test package already follows (see test_canvas.py/ -# test_agents.py/test_app_ws.py's own identical comment). +import backend # noqa: F401 - exercises the package import +# R7.2: api_provider.py sits at the repo root, a sibling of backend/ - the +# same directory pytest already put on sys.path to make `backend` itself +# importable, so this needs no setup and no particular ordering relative to +# the import above. import api_provider diff --git a/backend/tests/test_agent_layer_qt_free.py b/backend/tests/test_agent_layer_qt_free.py index ece726b6..7edc0a47 100644 --- a/backend/tests/test_agent_layer_qt_free.py +++ b/backend/tests/test_agent_layer_qt_free.py @@ -7,7 +7,6 @@ python subprocess and asserts PySide6 never entered sys.modules. """ -import os import subprocess import sys from pathlib import Path @@ -23,18 +22,16 @@ def _assert_import_is_qt_free(module_name: str) -> None: "assert not qt, f'importing {module_name} pulled Qt: {{qt}}'\n" f"print('{module_name} imported qt-free')\n" ) - # The legacy modules live in graphlink_app/ and are importable as - # top-level names (pyproject py-modules); a fresh subprocess needs that - # directory on its path the same way conftest arranges it in-process. - env = dict(os.environ) - env["PYTHONPATH"] = str(REPO_ROOT / "graphlink_app") + os.pathsep + env.get("PYTHONPATH", "") + # R7.2: these modules now sit at REPO_ROOT itself (a sibling of + # backend/), not inside graphlink_app/ - `python -c` sets sys.path[0] to + # cwd, and the subprocess already runs with cwd=REPO_ROOT below, so no + # PYTHONPATH injection is needed any more. result = subprocess.run( [sys.executable, "-c", code], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, - env=env, ) assert result.returncode == 0, ( f"importing {module_name} in a fresh process failed or pulled Qt:\n" diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index be21ce1e..561bdb5e 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -21,10 +21,10 @@ import pytest -# Importing any backend.* submodule runs backend/__init__.py first, which -# puts graphlink_app/ on sys.path - these must come before the bare -# top-level graphlink_app imports below (api_provider, graphlink_task_config, -# graphlink_licensing) for this module to import cleanly when run standalone. +# R7.2: api_provider/graphlink_task_config/graphlink_licensing (imported +# below, directly or via backend.agents) sit at the repo root, a sibling of +# backend/ - already on sys.path whenever this package is, no ordering +# constraint relative to the import below. import backend.agents as agents_module from backend.agents import AgentDispatcher from backend.canvas import SceneDocument, register_canvas diff --git a/backend/tests/test_app_ws.py b/backend/tests/test_app_ws.py index 9ed48c19..633f33d2 100644 --- a/backend/tests/test_app_ws.py +++ b/backend/tests/test_app_ws.py @@ -12,9 +12,8 @@ from backend import BACKEND_VERSION from backend.app import create_app -# Importing any backend.* submodule (above) runs backend/__init__.py first, -# which puts graphlink_app/ on sys.path - these bare top-level imports must -# come after it, same ordering rule backend/tests/test_agents.py documents. +# R7.2: api_provider/graphlink_task_config sit at the repo root, a sibling +# of backend/ - already importable, no ordering constraint. import api_provider import graphlink_task_config as config diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 680ad283..cec0ae3c 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -10,11 +10,9 @@ import pytest -# Importing any backend.* submodule runs backend/__init__.py first, which -# puts graphlink_app/ on sys.path - these must come before the bare -# top-level graphlink_app imports below (api_provider, graphlink_task_config) -# for this module to import cleanly when run standalone, not just as part of -# a larger session where some earlier-collected module already did this. +# R7.2: api_provider/graphlink_task_config (imported below, directly or via +# backend.agents) sit at the repo root, a sibling of backend/ - already on +# sys.path whenever this package is, no ordering constraint. import backend.agents as agents_module from backend.agents import AgentDispatcher from backend.canvas import ( diff --git a/backend/tests/test_crash_recovery_notice.py b/backend/tests/test_crash_recovery_notice.py index c808f4f7..7e884537 100644 --- a/backend/tests/test_crash_recovery_notice.py +++ b/backend/tests/test_crash_recovery_notice.py @@ -16,9 +16,8 @@ from backend.app import create_app from backend.crash_recovery import CRASH_NOTICE_MESSAGE -# Importing any backend.* submodule (above) runs backend/__init__.py first, -# which puts graphlink_app/ on sys.path - these bare top-level imports must -# come after it, same ordering rule backend/tests/test_agents.py documents. +# R7.2: api_provider/graphlink_task_config sit at the repo root, a sibling +# of backend/ - already importable, no ordering constraint. import api_provider import graphlink_task_config as config diff --git a/graphlink_app/graphlink_app.py b/graphlink_app/graphlink_app.py index 02495666..177095e8 100644 --- a/graphlink_app/graphlink_app.py +++ b/graphlink_app/graphlink_app.py @@ -1,5 +1,18 @@ import logging import sys +from pathlib import Path + +# R7.2: api_provider, graphlink_version, and 31 other modules every import +# below transitively needs (directly or via graphlink_window.py's own import +# graph) moved out of this directory to the repo root. Must run before ANY +# graphlink_* import - graphlink_window (the very next real import) already +# needs api_provider/graphlink_audio/graphlink_prompts/ +# graphlink_navigation_pins/graphlink_token_estimator, so inserting this any +# later is too late. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + from PySide6.QtWidgets import QApplication, QMessageBox from graphlink_window import ChatWindow diff --git a/graphlink_app/tests/conftest.py b/graphlink_app/tests/conftest.py index 00c0b530..ee8b6840 100644 --- a/graphlink_app/tests/conftest.py +++ b/graphlink_app/tests/conftest.py @@ -11,7 +11,17 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +# R7.2: the flat modules under graphlink_app/ (still ~89 of them, plus every +# remaining Qt widget/bridge file) need graphlink_app/ itself on sys.path - +# unchanged from before. The 33 modules R7.2 relocated (api_provider, +# graphlink_task_config, graphlink_plugins/, ...) now sit at the repo root +# instead, a sibling of graphlink_app/, so this suite additionally needs the +# repo root on sys.path or every bare `import api_provider` etc. across this +# test package fails collection. +_GRAPHLINK_APP_DIR = Path(__file__).resolve().parent.parent +_REPO_ROOT = _GRAPHLINK_APP_DIR.parent +sys.path.insert(0, str(_GRAPHLINK_APP_DIR)) +sys.path.insert(0, str(_REPO_ROOT)) from PySide6.QtWidgets import QApplication diff --git a/graphlink_app/tests/test_ui_token_acceptance.py b/graphlink_app/tests/test_ui_token_acceptance.py index 83d458a3..de5b0aab 100644 --- a/graphlink_app/tests/test_ui_token_acceptance.py +++ b/graphlink_app/tests/test_ui_token_acceptance.py @@ -56,10 +56,6 @@ "grid-color preset swatches - user-pickable data choices, stable " "across themes by design" ), - "graphlink_grid_view_settings.py": ( - "DEFAULT_GRID_COLOR in the deliberately Qt-free plain-data settings " - "model - a persisted user-preference default, not chrome" - ), "graphlink_canvas/graphlink_canvas_chart_item.py": ( "single #868686 'slate' entry in the chart data-series color cycle - " "series colors are data, not chrome (rule 2 of the sweep)" @@ -69,16 +65,6 @@ "color default (saved into scene JSON) - scene data must keep its " "color across theme switches, like DEFAULT_GRID_COLOR" ), - "graphlink_chart_rendering.py": ( - "R6.2: Qt-free Matplotlib chart rendering, imported directly by " - "backend/ (must stay importable with zero Qt in its dependency " - "chain, so it cannot import graphlink_config/graphlink_styles - both " - "pull in Qt). Its dark-theme palette (SURFACE/PANEL/BORDER/TEXT/ " - "MUTED/GRID/PRIMARY/SECONDARY/SELECTION) is copied VERBATIM from " - "graphlink_config.py's THEME_TOKENS['dark'], and SLATE=#868686 is " - "the same already-allowlisted data-series entry from " - "graphlink_canvas_chart_item.py above, not a new color choice." - ), } # Files allowed to carry the literal string "Segoe UI" outside the token @@ -89,11 +75,6 @@ "'Segoe UI Variable' as CHOICES - data presented to the user, not a " "hardcoded app default (defaults go through FONT_FAMILY_NAME)" ), - "graphlink_chart_rendering.py": ( - "R6.2: same Qt-free-dependency-chain constraint as its _HEX_ALLOWLIST " - "entry above - cannot import graphlink_styles.FONT_FAMILY_NAME " - "(pulls in Qt), so its value ('Segoe UI') is copied verbatim instead" - ), } _HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b") diff --git a/graphlink_app/graphlink_artifact_agent.py b/graphlink_artifact_agent.py similarity index 100% rename from graphlink_app/graphlink_artifact_agent.py rename to graphlink_artifact_agent.py diff --git a/graphlink_app/graphlink_audio.py b/graphlink_audio.py similarity index 100% rename from graphlink_app/graphlink_audio.py rename to graphlink_audio.py diff --git a/graphlink_app/graphlink_chart_agent.py b/graphlink_chart_agent.py similarity index 100% rename from graphlink_app/graphlink_chart_agent.py rename to graphlink_chart_agent.py diff --git a/graphlink_app/graphlink_chart_data.py b/graphlink_chart_data.py similarity index 100% rename from graphlink_app/graphlink_chart_data.py rename to graphlink_chart_data.py diff --git a/graphlink_app/graphlink_chart_rendering.py b/graphlink_chart_rendering.py similarity index 100% rename from graphlink_app/graphlink_chart_rendering.py rename to graphlink_chart_rendering.py diff --git a/graphlink_app/graphlink_chat_agent.py b/graphlink_chat_agent.py similarity index 100% rename from graphlink_app/graphlink_chat_agent.py rename to graphlink_chat_agent.py diff --git a/graphlink_app/graphlink_grid_view_settings.py b/graphlink_grid_view_settings.py similarity index 100% rename from graphlink_app/graphlink_grid_view_settings.py rename to graphlink_grid_view_settings.py diff --git a/graphlink_app/graphlink_licensing.py b/graphlink_licensing.py similarity index 100% rename from graphlink_app/graphlink_licensing.py rename to graphlink_licensing.py diff --git a/graphlink_app/graphlink_memory.py b/graphlink_memory.py similarity index 100% rename from graphlink_app/graphlink_memory.py rename to graphlink_memory.py diff --git a/graphlink_app/graphlink_model_catalog.py b/graphlink_model_catalog.py similarity index 100% rename from graphlink_app/graphlink_model_catalog.py rename to graphlink_model_catalog.py diff --git a/graphlink_app/graphlink_navigation_pins.py b/graphlink_navigation_pins.py similarity index 100% rename from graphlink_app/graphlink_navigation_pins.py rename to graphlink_navigation_pins.py diff --git a/graphlink_app/graphlink_plugins/__init__.py b/graphlink_plugins/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/__init__.py rename to graphlink_plugins/__init__.py diff --git a/graphlink_app/graphlink_plugins/code_sandbox/__init__.py b/graphlink_plugins/code_sandbox/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/code_sandbox/__init__.py rename to graphlink_plugins/code_sandbox/__init__.py diff --git a/graphlink_app/graphlink_plugins/code_sandbox/domain.py b/graphlink_plugins/code_sandbox/domain.py similarity index 100% rename from graphlink_app/graphlink_plugins/code_sandbox/domain.py rename to graphlink_plugins/code_sandbox/domain.py diff --git a/graphlink_app/graphlink_plugins/common/__init__.py b/graphlink_plugins/common/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/common/__init__.py rename to graphlink_plugins/common/__init__.py diff --git a/graphlink_app/graphlink_plugins/common/github_client.py b/graphlink_plugins/common/github_client.py similarity index 100% rename from graphlink_app/graphlink_plugins/common/github_client.py rename to graphlink_plugins/common/github_client.py diff --git a/graphlink_app/graphlink_plugins/common/llm_json.py b/graphlink_plugins/common/llm_json.py similarity index 100% rename from graphlink_app/graphlink_plugins/common/llm_json.py rename to graphlink_plugins/common/llm_json.py diff --git a/graphlink_app/graphlink_plugins/gitlink/__init__.py b/graphlink_plugins/gitlink/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/gitlink/__init__.py rename to graphlink_plugins/gitlink/__init__.py diff --git a/graphlink_app/graphlink_plugins/gitlink/agent.py b/graphlink_plugins/gitlink/agent.py similarity index 100% rename from graphlink_app/graphlink_plugins/gitlink/agent.py rename to graphlink_plugins/gitlink/agent.py diff --git a/graphlink_app/graphlink_plugins/gitlink/repository.py b/graphlink_plugins/gitlink/repository.py similarity index 100% rename from graphlink_app/graphlink_plugins/gitlink/repository.py rename to graphlink_plugins/gitlink/repository.py diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_context_menu.py b/graphlink_plugins/graphlink_plugin_context_menu.py similarity index 100% rename from graphlink_app/graphlink_plugins/graphlink_plugin_context_menu.py rename to graphlink_plugins/graphlink_plugin_context_menu.py diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py b/graphlink_plugins/graphlink_plugin_portal.py similarity index 100% rename from graphlink_app/graphlink_plugins/graphlink_plugin_portal.py rename to graphlink_plugins/graphlink_plugin_portal.py diff --git a/graphlink_app/graphlink_plugins/pycoder/__init__.py b/graphlink_plugins/pycoder/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/pycoder/__init__.py rename to graphlink_plugins/pycoder/__init__.py diff --git a/graphlink_app/graphlink_plugins/pycoder/domain.py b/graphlink_plugins/pycoder/domain.py similarity index 100% rename from graphlink_app/graphlink_plugins/pycoder/domain.py rename to graphlink_plugins/pycoder/domain.py diff --git a/graphlink_app/graphlink_plugins/web_research/__init__.py b/graphlink_plugins/web_research/__init__.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/__init__.py rename to graphlink_plugins/web_research/__init__.py diff --git a/graphlink_app/graphlink_plugins/web_research/domain.py b/graphlink_plugins/web_research/domain.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/domain.py rename to graphlink_plugins/web_research/domain.py diff --git a/graphlink_app/graphlink_plugins/web_research/fetch_policy.py b/graphlink_plugins/web_research/fetch_policy.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/fetch_policy.py rename to graphlink_plugins/web_research/fetch_policy.py diff --git a/graphlink_app/graphlink_plugins/web_research/ports.py b/graphlink_plugins/web_research/ports.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/ports.py rename to graphlink_plugins/web_research/ports.py diff --git a/graphlink_app/graphlink_plugins/web_research/providers.py b/graphlink_plugins/web_research/providers.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/providers.py rename to graphlink_plugins/web_research/providers.py diff --git a/graphlink_app/graphlink_plugins/web_research/service.py b/graphlink_plugins/web_research/service.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/service.py rename to graphlink_plugins/web_research/service.py diff --git a/graphlink_app/graphlink_plugins/web_research/worker.py b/graphlink_plugins/web_research/worker.py similarity index 100% rename from graphlink_app/graphlink_plugins/web_research/worker.py rename to graphlink_plugins/web_research/worker.py diff --git a/graphlink_app/graphlink_prompts.py b/graphlink_prompts.py similarity index 100% rename from graphlink_app/graphlink_prompts.py rename to graphlink_prompts.py diff --git a/graphlink_app/graphlink_secrets.py b/graphlink_secrets.py similarity index 100% rename from graphlink_app/graphlink_secrets.py rename to graphlink_secrets.py diff --git a/graphlink_app/graphlink_task_config.py b/graphlink_task_config.py similarity index 100% rename from graphlink_app/graphlink_task_config.py rename to graphlink_task_config.py diff --git a/graphlink_app/graphlink_token_estimator.py b/graphlink_token_estimator.py similarity index 100% rename from graphlink_app/graphlink_token_estimator.py rename to graphlink_token_estimator.py diff --git a/graphlink_app/graphlink_version.py b/graphlink_version.py similarity index 100% rename from graphlink_app/graphlink_version.py rename to graphlink_version.py diff --git a/pyproject.toml b/pyproject.toml index 784840a2..6a0d794f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,25 +67,32 @@ Homepage = "https://github.com/dovvnloading/Graphlink" # explicitly since setuptools' auto-discovery only picks up real packages (__init__.py # subdirectories), not loose modules. [tool.setuptools] -package-dir = { "" = "graphlink_app" } +# R7.2: the 17 modules relocated out of graphlink_app/ to the repo root +# (api_provider, graphlink_task_config, graphlink_licensing, ...) are +# deliberately NOT listed here any more - setuptools' build_py only +# consults package_dir[""] to locate a flat py-modules entry, and per-module +# package_dir overrides are silently ignored for modules (they only work for +# real packages), so there is no way to keep listing them here while they +# live outside graphlink_app/. This means a real wheel/sdist build +# (`python -m build`, `pip install -e .`) will not bundle these 17 modules - +# a known, accepted, currently-inert gap: nothing in this project's actual +# pipeline runs `pip install -e .` today (CI only does `pip install -r +# requirements.txt`, which needs no packaging metadata at all), so nothing +# breaks in practice. This closes at the R7.6 cutover, when graphlink_app/ +# collapses entirely and this whole package-dir/py-modules scheme gets +# rebuilt in one motion for a flat single-root layout. py-modules = [ - "api_provider", "graphlink_agents", "graphlink_agents_artifact", - "graphlink_artifact_agent", "graphlink_agents_code_sandbox", "graphlink_agents_core", "graphlink_agents_pycoder", "graphlink_agents_tools", "graphlink_agents_web", "graphlink_app", - "graphlink_audio", "graphlink_canvas_groups", "graphlink_canvas_items", "graphlink_canvas_note_items", - "graphlink_chart_agent", - "graphlink_chart_data", - "graphlink_chart_rendering", "graphlink_command_palette", "graphlink_command_palette_bridge", "graphlink_command_palette_payload", @@ -102,8 +109,6 @@ py-modules = [ "graphlink_composer_popup_positioning", "graphlink_composer_web", "graphlink_config", - "graphlink_task_config", - "graphlink_chat_agent", "graphlink_context_menu", "graphlink_connections", "graphlink_conversation_node", @@ -121,20 +126,15 @@ py-modules = [ "graphlink_grid_control_bridge", "graphlink_grid_control_payload", "graphlink_grid_control_web", - "graphlink_grid_view_settings", "graphlink_html_view", "graphlink_island_bridge", "graphlink_island_codegen", "graphlink_island_schema", - "graphlink_licensing", "graphlink_lod", "graphlink_logging", - "graphlink_memory", "graphlink_minimap_bridge", "graphlink_minimap_payload", "graphlink_minimap_web", - "graphlink_model_catalog", - "graphlink_navigation_pins", "graphlink_node", "graphlink_notification_bridge", "graphlink_notification_payload", @@ -147,13 +147,11 @@ py-modules = [ "graphlink_plugin_picker_bridge", "graphlink_plugin_picker_payload", "graphlink_plugin_picker_web", - "graphlink_prompts", "graphlink_renderer_flags", "graphlink_scene", "graphlink_search_overlay_bridge", "graphlink_search_overlay_payload", "graphlink_search_overlay_web", - "graphlink_secrets", "graphlink_settings_bridge", "graphlink_settings_payload", "graphlink_settings_web", @@ -162,13 +160,11 @@ py-modules = [ "graphlink_token_counter_bridge", "graphlink_token_counter_payload", "graphlink_token_counter_web", - "graphlink_token_estimator", "graphlink_toolbar_bridge", "graphlink_toolbar_payload", "graphlink_toolbar_web", "graphlink_utility", "graphlink_update", - "graphlink_version", "graphlink_view", "graphlink_web_island_host", "graphlink_webengine", @@ -177,8 +173,24 @@ py-modules = [ "graphlink_window_navigation", ] +# R7.2: graphlink_plugins/ moved out of graphlink_app/ WHOLESALE - all 19 +# files, including 3 that are still Qt-tainted (graphlink_plugin_context_menu.py, +# graphlink_plugin_portal.py, web_research/worker.py). An earlier version of +# this move tried to leave those 3 behind at the old location to keep the new +# home Qt-pure; that broke at runtime (ModuleNotFoundError), because Python +# resolves a dotted import like `graphlink_plugins.graphlink_plugin_portal` +# against ONE specific `graphlink_plugins` package - whichever directory of +# that name comes first on sys.path - and two same-named packages can never +# coexist. graphlink_plugins/ is therefore NOT 100% Qt-free at its new home; +# that closes at the R7.6 cutover when the 3 remaining Qt files finally get +# deleted along with the rest of graphlink_app/. packages.find's own `where` +# list now searches both roots; the package-dir override below is required +# alongside it - package-dir[""] applies to every discovered package +# regardless of which `where` root found it, so without this entry a real +# build looks for graphlink_plugins under graphlink_app/ (where it no longer +# exists) and fails outright rather than silently mis-packaging. [tool.setuptools.packages.find] -where = ["graphlink_app"] +where = ["graphlink_app", "."] include = [ "graphlink_canvas*", "graphlink_nodes*", @@ -188,5 +200,22 @@ include = [ "graphlink_widgets*", ] +[tool.setuptools.package-dir] +"" = "graphlink_app" +graphlink_plugins = "graphlink_plugins" + +# R7.2: this now hard-fails a real `python -m build`/`pip install -e .` +# with `ModuleNotFoundError: graphlink_version` - verified empirically, not +# guessed. setuptools' `attr:` resolution for a bare (non-dotted) module name +# ONLY ever consults package_dir[""] (confirmed by direct testing: even a +# dedicated `graphlink_version = "."` package-dir entry, the same mechanism +# that fixes graphlink_plugins above, does NOT help attr: resolution the way +# it helps packages.find - they are different code paths), so as long as +# package_dir[""] must stay "graphlink_app" for the ~89 modules still there, +# there is no clean way to also point this at graphlink_version.py's new +# repo-root location. Same accepted-gap tier as py-modules above, but more +# severe (a hard failure, not silent under-packaging) - stated plainly so +# nobody is surprised by it. Closes at the R7.6 cutover along with everything +# else in this file keyed to graphlink_app/. [tool.setuptools.dynamic] version = { attr = "graphlink_version.APP_VERSION" }