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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand All @@ -42,7 +42,7 @@ The standing branch → push → PR → merge process for this repository. It co
- **Branch naming:** `agent/<short-kebab-slug>` 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
Expand All @@ -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 .
```
Expand Down
File renamed without changes.
24 changes: 11 additions & 13 deletions backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
123 changes: 96 additions & 27 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
11 changes: 4 additions & 7 deletions backend/tests/test_agent_layer_qt_free.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
python subprocess and asserts PySide6 never entered sys.modules.
"""

import os
import subprocess
import sys
from pathlib import Path
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions backend/tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions backend/tests/test_app_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 3 additions & 5 deletions backend/tests/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
5 changes: 2 additions & 3 deletions backend/tests/test_crash_recovery_notice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions graphlink_app/graphlink_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 11 additions & 1 deletion graphlink_app/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 0 additions & 19 deletions graphlink_app/tests/test_ui_token_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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
Expand All @@ -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")
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading
Loading