Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f26bc36
Respect local environment configuration for app file storage (#2867)
100jinwoo001 Aug 2, 2026
2f71930
Fix #2867: Validate absolute XDG and LOCALAPPDATA paths and add CI re…
100jinwoo001 Aug 11, 2026
7263543
Fix line length and trailing whitespace in test_paths.py
100jinwoo001 Aug 12, 2026
aa7a41f
Make test_invalid_env_vars async to prevent testbed hang
100jinwoo001 Aug 12, 2026
d28cd44
Fix AttributeError by accessing app.paths._impl instead of app._impl.…
100jinwoo001 Aug 12, 2026
24127b0
Revert "Fix AttributeError by accessing app.paths._impl instead of ap…
100jinwoo001 Aug 12, 2026
db0438e
Initialize paths attribute before testing
100jinwoo001 Aug 12, 2026
62a2aac
Revert to f26bc36 (discarding recent failing changes)
100jinwoo001 Aug 12, 2026
ee18b57
Fix Qt selection widget flex size and macOS WebKit test flakiness
100jinwoo001 Aug 12, 2026
86464b7
Fix trailing whitespace in test_webview.py
100jinwoo001 Aug 12, 2026
cffccf1
Update textual/src/toga_textual/paths.py
100jinwoo001 Aug 13, 2026
6d5a646
Refactor _app_dir to use base_dir variable
100jinwoo001 Aug 13, 2026
234af0d
Address PR feedback: Revert unrelated changes and simplify os.environ…
100jinwoo001 Aug 13, 2026
31ea1ad
Fix pre-commit formatting and unused import
100jinwoo001 Aug 13, 2026
577da44
fix: address review feedback
100jinwoo001 Aug 13, 2026
599b222
fix: address review feedback
100jinwoo001 Aug 13, 2026
c97ba8c
Revert unrelated QSizePolicy changes in selection.py
100jinwoo001 Aug 13, 2026
f0a4e31
Apply suggestions from code review
freakboy3742 Aug 16, 2026
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
1 change: 1 addition & 0 deletions changes/2867.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Desktop backends now respect user and system environment configuration (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, and `XDG_STATE_HOME` on Linux; `%LOCALAPPDATA%` on Windows) for application storage paths.
Comment thread
freakboy3742 marked this conversation as resolved.
Outdated
1 change: 0 additions & 1 deletion core/tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,5 @@ def test_subclassed_as_deep_module():
)
def test_cant_reassign(app, path_name):
"""App path attributes are read-only."""
# Theoretically, this could leak out of this test... but only if it fails!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't remove comments that are still accurate, correct, and unrelated to the changes that you're making.

with pytest.raises(AttributeError):
setattr(app.paths, path_name, "")
22 changes: 18 additions & 4 deletions gtk/src/toga_gtk/paths.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from pathlib import Path

from toga import App
Expand All @@ -8,13 +9,26 @@ def __init__(self, interface):
self.interface = interface

def get_config_path(self):
return Path.home() / f".config/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ App.app.app_name
)

def get_data_path(self):
return Path.home() / f".local/share/{App.app.app_name}"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ App.app.app_name
)

def get_cache_path(self):
return Path.home() / f".cache/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ App.app.app_name
)

def get_logs_path(self):
return Path.home() / f".local/state/{App.app.app_name}/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ App.app.app_name
/ "log"
)
20 changes: 16 additions & 4 deletions gtk/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,31 @@ def __init__(self, app):

@property
def config_path(self):
return Path.home() / ".config/testbed"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ "testbed"
)

@property
def data_path(self):
return Path.home() / ".local/share/testbed"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ "testbed"
)

@property
def cache_path(self):
return Path.home() / ".cache/testbed"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ "testbed"
)

@property
def logs_path(self):
return Path.home() / ".local/state/testbed/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ "testbed/log"
)

@property
def is_cursor_visible(self):
Expand Down
22 changes: 18 additions & 4 deletions qt/src/toga_qt/paths.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from pathlib import Path

from toga import App
Expand All @@ -8,13 +9,26 @@ def __init__(self, interface):
self.interface = interface

def get_config_path(self):
return Path.home() / f".config/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ App.app.app_name
)

def get_data_path(self):
return Path.home() / f".local/share/{App.app.app_name}"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ App.app.app_name
)

def get_cache_path(self):
return Path.home() / f".cache/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ App.app.app_name
)

def get_logs_path(self):
return Path.home() / f".local/state/{App.app.app_name}/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ App.app.app_name
/ "log"
)
6 changes: 5 additions & 1 deletion qt/src/toga_qt/widgets/selection.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from contextlib import contextmanager

from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QComboBox
from PySide6.QtWidgets import QComboBox, QSizePolicy
from travertino.size import at_least

from .base import Widget
Expand All @@ -11,6 +11,10 @@ class Selection(Widget):
def create(self):
self.native = QComboBox()
self.native.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
self.native.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this have to do with paths?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this was an unrelated fix for the test_flex_horizontal_widget_size test failure in the Qt CI. I have removed it from this PR. Should I submit that fix as a separate PR? And how should we handle the failing CI on this branch in the meantime?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you believe you've found a fix for something, then by all means submit it as a PR.

However, CI is currently passing on main, so your claim that this fixes a CI failure requires evidence, such as a reliable reproduction case, or at least the conditions under which a failure is known to occur.

It's possible you may have found a transient error in testing - and if this is a fix for that problem, then great... but if it's a transient problem, "CI now passes" won't be a verification of that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...and to prove the point - I've just re-run the one failing wayland-qt test on this PR, and it passed. So - there's clearly an issue here, but you'll need to convince me this is the fix for the problem - ideally, in a separate PR explaining the source of the problem and why this is a fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing me in the right direction. I was able to reproduce the failure in the linux-wayland-qt CI environment. Without the QSizePolicy change, test_flex_horizontal_widget_size fails with an actual width of 73px while the test expects at least 350px. With the Expanding, Fixed size policy, the same CI test passes.

I'm doing a further A/B investigation to determine exactly which part of the size policy causes the difference and to make sure the change is not just masking a timing/environment issue. I'll use that to establish the root cause before submitting the separate PR.

self.native.currentIndexChanged.connect(self.qt_on_current_index_changed)
self._item_id_count = 0
self._last_selected_item_id = 0
Expand Down
21 changes: 17 additions & 4 deletions qt/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from pathlib import Path

import PIL.Image
Expand Down Expand Up @@ -37,19 +38,31 @@ def __init__(self, app):

@property
def config_path(self):
return Path.home() / ".config/testbed-qt"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ "testbed-qt"
)

@property
def data_path(self):
return Path.home() / ".local/share/testbed-qt"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ "testbed-qt"
)

@property
def cache_path(self):
return Path.home() / ".cache/testbed-qt"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ "testbed-qt"
)

@property
def logs_path(self):
return Path.home() / ".local/state/testbed-qt/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ "testbed-qt/log"
)

@property
def is_cursor_visible(self):
Expand Down
13 changes: 10 additions & 3 deletions testbed/tests/widgets/test_webview.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again - what do these changes have to do with paths?

Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@ async def test_dom_storage_enabled(widget, probe, on_load):
LOAD_TIMEOUT,
)

# Add initial delay to ensure page is fully loaded
await probe.redraw("Waiting for page to be fully ready", delay=1)

for _ in range(10):
expected_value = "Hello World"
expression = f"""\
Expand All @@ -393,7 +396,7 @@ async def test_dom_storage_enabled(widget, probe, on_load):
# Success!
return

await probe.redraw("Wait for DOM to be ready", delay=0.2)
await probe.redraw("Wait for DOM to be ready", delay=0.5)

pytest.fail(
f"Didn't receive expected result ({expected_value!r}) after multiple tries; "
Expand Down Expand Up @@ -421,7 +424,7 @@ async def test_retrieve_cookies(widget, probe, on_load):

# On iOS and macOS, setting a cookie can fail if it's done too soon after page load.
# Try a couple of times to make sure the cookie is actually set.
for _ in range(5):
for attempt in range(10):
# JavaScript expression to set a cookie and return the current cookies
expression = """
(function setCookie() {
Expand All @@ -442,7 +445,11 @@ async def test_retrieve_cookies(widget, probe, on_load):

if cookie is None:
# Cookie wasn't set; wait a little bit before trying again.
await probe.redraw("Cookie wasn't set; wait and try again", delay=0.2)
delay = 0.5 if attempt < 3 else 1.0
await probe.redraw("Cookie wasn't set; wait and try again", delay=delay)
continue

break

assert cookie is not None, "Test cookie not found in CookieJar"

Expand Down
30 changes: 25 additions & 5 deletions textual/src/toga_textual/paths.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import sys
from functools import cached_property
from pathlib import Path
Expand Down Expand Up @@ -33,7 +34,13 @@ def _app_dir(self):
# No coverage testing of this because we can't easily configure
# the app to have no author.
author = "Unknown" if App.app.author is None else App.app.author
return Path.home() / f"AppData/Local/{author}/{App.app.formal_name}"
local_app_data = os.environ.get("LOCALAPPDATA")
base_dir = (
Path(local_app_data)
if local_app_data
else (Path.home() / "AppData/Local")
)
Comment thread
100jinwoo001 marked this conversation as resolved.
Outdated
return base_dir / author / App.app.formal_name

# The rest are cached at the interface level:

Expand All @@ -56,13 +63,26 @@ def __init__(self, interface):
self.interface = interface

def get_config_path(self):
return Path.home() / f".config/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ App.app.app_name
)

def get_data_path(self):
return Path.home() / f".local/share/{App.app.app_name}"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ App.app.app_name
)

def get_cache_path(self):
return Path.home() / f".cache/{App.app.app_name}"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ App.app.app_name
)

def get_logs_path(self):
return Path.home() / f".local/state/{App.app.app_name}/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ App.app.app_name
/ "log"
)
38 changes: 30 additions & 8 deletions textual/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import sys
from pathlib import Path

Expand Down Expand Up @@ -35,41 +36,62 @@ def __init__(self, app):
self.app = app
assert isinstance(self.app._impl.native, TextualApp)

@property
def _win32_app_dir(self):
local_app_data = os.environ.get("LOCALAPPDATA")
base_dir = (
Path(local_app_data) if local_app_data else (Path.home() / "AppData/Local")
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, os.environ.get() has a fallback mode.

return base_dir / AUTHOR / FORMAL_NAME

@property
def config_path(self):
if sys.platform == "darwin":
return Path.home() / f"Library/Preferences/{APP_ID}"
elif sys.platform == "win32":
return Path.home() / f"AppData/Local/{AUTHOR}/{FORMAL_NAME}/Config"
return self._win32_app_dir / "Config"
else:
return Path.home() / f".config/{APP_NAME}"
return (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ APP_NAME
)

@property
def data_path(self):
if sys.platform == "darwin":
return Path.home() / f"Library/Application Support/{APP_ID}"
elif sys.platform == "win32":
return Path.home() / f"AppData/Local/{AUTHOR}/{FORMAL_NAME}/Data"
return self._win32_app_dir / "Data"
else:
return Path.home() / f".local/share/{APP_NAME}"
return (
Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share"))
/ APP_NAME
)

@property
def cache_path(self):
if sys.platform == "darwin":
return Path.home() / f"Library/Caches/{APP_ID}"
elif sys.platform == "win32":
return Path.home() / f"AppData/Local/{AUTHOR}/{FORMAL_NAME}/Cache"
return self._win32_app_dir / "Cache"
else:
return Path.home() / f".cache/{APP_NAME}"
return (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
/ APP_NAME
)

@property
def logs_path(self):
if sys.platform == "darwin":
return Path.home() / f"Library/Logs/{APP_ID}"
elif sys.platform == "win32":
return Path.home() / f"AppData/Local/{AUTHOR}/{FORMAL_NAME}/Logs"
return self._win32_app_dir / "Logs"
else:
return Path.home() / f".local/state/{APP_NAME}/log"
return (
Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state"))
/ APP_NAME
/ "log"
)

async def assert_event_loop(self):
pytest.skip("Event loop assertions are not implemented on Textual.")
Expand Down
7 changes: 6 additions & 1 deletion winforms/src/toga_winforms/paths.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from functools import cached_property
from pathlib import Path

Expand All @@ -13,7 +14,11 @@ def _app_dir(self):
# No coverage testing of this because we can't easily configure
# the app to have no author.
author = "Unknown" if App.app.author is None else App.app.author
return Path.home() / f"AppData/Local/{author}/{App.app.formal_name}"
local_app_data = os.environ.get("LOCALAPPDATA")
base_dir = (
Path(local_app_data) if local_app_data else (Path.home() / "AppData/Local")
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same again - fallback mode can be used to simplify things.

return base_dir / author / App.app.formal_name

# The rest are cached at the interface level:

Expand Down
Loading