From 359b1ea9d03e84a357cbbf72f1becbe65931ac96 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 6 Jun 2023 14:57:09 +0800 Subject: [PATCH 01/17] Rework implementation of app paths to factor out common implementations. --- android/src/toga_android/factory.py | 4 +- android/src/toga_android/paths.py | 36 +-- cocoa/src/toga_cocoa/factory.py | 4 +- cocoa/src/toga_cocoa/paths.py | 37 +-- cocoa/tests/test_paths.py | 198 ------------- core/MANIFEST.in | 2 - core/src/toga/app.py | 29 +- core/src/toga/icons.py | 5 +- core/src/toga/images.py | 3 +- core/src/toga/paths.py | 67 +++++ core/tests/test_deprecated_factory.py | 3 +- core/tests/test_paths.py | 276 ++++++++---------- core/tests/testbed/bootstrap.py | 11 - core/tests/testbed/customize/sitecustomize.py | 3 + .../testbed/installed.dist-info/INSTALLER | 1 - .../testbed/installed.dist-info/METADATA | 10 - core/tests/testbed/installed/__init__.py | 0 core/tests/testbed/installed/__main__.py | 4 - core/tests/testbed/installed/app.py | 26 -- .../testbed/{standalone.py => interactive.py} | 21 +- core/tests/testbed/simple/app.py | 12 +- core/tests/testbed/subclassed/app.py | 12 +- docs/reference/api/resources/index.rst | 1 + docs/reference/api/resources/paths.rst | 52 ++++ docs/reference/data/widgets_by_platform.csv | 1 + dummy/src/toga_dummy/factory.py | 4 +- dummy/src/toga_dummy/paths.py | 37 +-- gtk/src/toga_gtk/factory.py | 4 +- gtk/src/toga_gtk/paths.py | 37 +-- gtk/tests/test_paths.py | 198 ------------- iOS/src/toga_iOS/factory.py | 4 +- iOS/src/toga_iOS/paths.py | 45 +-- web/src/toga_web/factory.py | 4 +- web/src/toga_web/paths.py | 42 +-- winforms/src/toga_winforms/factory.py | 4 +- winforms/src/toga_winforms/paths.py | 46 +-- winforms/tests/test_paths.py | 207 ------------- 37 files changed, 350 insertions(+), 1100 deletions(-) delete mode 100644 cocoa/tests/test_paths.py create mode 100644 core/src/toga/paths.py delete mode 100644 core/tests/testbed/bootstrap.py create mode 100644 core/tests/testbed/customize/sitecustomize.py delete mode 100644 core/tests/testbed/installed.dist-info/INSTALLER delete mode 100644 core/tests/testbed/installed.dist-info/METADATA delete mode 100644 core/tests/testbed/installed/__init__.py delete mode 100644 core/tests/testbed/installed/__main__.py delete mode 100644 core/tests/testbed/installed/app.py rename core/tests/testbed/{standalone.py => interactive.py} (56%) create mode 100644 docs/reference/api/resources/paths.rst delete mode 100644 gtk/tests/test_paths.py delete mode 100644 winforms/tests/test_paths.py diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 892a9a517f..3f5d6fecce 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -4,7 +4,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -59,6 +59,6 @@ def not_implemented(feature): "Window", "DetailedList", "not_implemented", - "paths", + "Paths", "dialogs", ] diff --git a/android/src/toga_android/paths.py b/android/src/toga_android/paths.py index 5b22781ef0..190cafaeb5 100644 --- a/android/src/toga_android/paths.py +++ b/android/src/toga_android/paths.py @@ -1,42 +1,24 @@ -import sys from pathlib import Path -import toga from toga import App class Paths: - # Allow instantiating Path object via the factory - Path = Path + def __init__(self, interface): + self.interface = interface @property def __context(self): return App.app._impl.native.getApplicationContext() - def __init__(self): - # On Android, __main__ only exists during app startup, so cache its location now. - self._app = Path(sys.modules["__main__"].__file__).parent + def get_config_path(self): + return Path(self.__context.getFilesDir().getPath()) / "log" - @property - def app(self): - return self._app - - @property - def data(self): - return Path(self.__context.getFilesDir().getPath()) + def get_data_path(self): + return Path(self.__context.getFilesDir().getPath()) / "data" - @property - def cache(self): + def get_cache_path(self): return Path(self.__context.getCacheDir().getPath()) - @property - def logs(self): - return self.data - - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent - - -paths = Paths() + def get_logs_path(self): + return Path(self.__context.getFilesDir().getPath()) / "log" diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index 1bc17859dd..c16e2ab0e6 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -7,7 +7,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths # Widgets from .widgets.activityindicator import ActivityIndicator @@ -50,7 +50,7 @@ def not_implemented(feature): "Font", "Icon", "Image", - "paths", + "Paths", "dialogs", # Widgets "ActivityIndicator", diff --git a/cocoa/src/toga_cocoa/paths.py b/cocoa/src/toga_cocoa/paths.py index 0f68fa7052..bcb00053c3 100644 --- a/cocoa/src/toga_cocoa/paths.py +++ b/cocoa/src/toga_cocoa/paths.py @@ -1,43 +1,20 @@ -import sys from pathlib import Path -import toga from toga import App class Paths: - # Allow instantiating Path object via the factory - Path = Path + def __init__(self, interface): + self.interface = interface - @property - def app(self): - try: - return Path(sys.modules["__main__"].__file__).parent - except KeyError: - # If we're running in test conditions, - # there is no __main__ module. - return Path.cwd() - except AttributeError: - # If we're running at an interactive prompt, - # the __main__ module isn't file-based. - return Path.cwd() + def get_config_path(self): + return Path.home() / "Library" / "Application Support" / App.app.app_id - @property - def data(self): + def get_data_path(self): return Path.home() / "Library" / "Application Support" / App.app.app_id - @property - def cache(self): + def get_cache_path(self): return Path.home() / "Library" / "Caches" / App.app.app_id - @property - def logs(self): + def get_logs_path(self): return Path.home() / "Library" / "Logs" / App.app.app_id - - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent - - -paths = Paths() diff --git a/cocoa/tests/test_paths.py b/cocoa/tests/test_paths.py deleted file mode 100644 index 90a928a8eb..0000000000 --- a/cocoa/tests/test_paths.py +++ /dev/null @@ -1,198 +0,0 @@ -import subprocess -import sys -import unittest -from pathlib import Path - -import toga - -TESTBED_PATH = Path(__file__).parent.parent.parent / "core" / "tests" / "testbed" - - -class TestPaths(unittest.TestCase): - def setUp(self): - # We use the existence of a __main__ module as a proxy for being in test - # conditions. This isn't *great*, but the __main__ module isn't meaningful - # during tests, and removing it allows us to avoid having explicit "if - # under test conditions" checks in paths.py. - if "__main__" in sys.modules: - del sys.modules["__main__"] - - def test_as_test(self): - "During test conditions, the app path is the current working directory" - app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - author="Jane Developer", - ) - - self.assertEqual( - app.paths.app, - Path.cwd(), - ) - self.assertEqual( - app.paths.data, - Path.home() / "Library" / "Application Support" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.cache, - Path.home() / "Library" / "Caches" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.logs, - Path.home() / "Library" / "Logs" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.toga, - Path(toga.__file__).parent, - ) - - def assert_standalone_paths(self, output): - "Assert the paths for the standalone app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / 'Library' / 'Application Support' / 'org.testbed.standalone-app'}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / 'Library' / 'Caches' / 'org.testbed.standalone-app'}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / 'Library' / 'Logs' / 'org.testbed.standalone-app'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) - - def test_as_interactive(self): - "At an interactive prompt, the app path is the current working directory" - # Spawn the standalone app using the interactive-mode mocking entry point - output = subprocess.check_output( - [sys.executable, "standalone.py", "--interactive"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_file(self): - "When started as `python app.py`, the app path is the folder holding app.py" - # Spawn the standalone app using `app.py` - output = subprocess.check_output( - [sys.executable, "standalone.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_module(self): - "When started as `python -m app`, the app path is the folder holding app.py" - # Spawn the standalone app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "standalone"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def assert_simple_paths(self, output): - "Assert the paths for the simple app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH / 'simple'}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / 'Library' / 'Application Support' / 'org.testbed.simple-app'}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / 'Library' / 'Caches' / 'org.testbed.simple-app'}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / 'Library' / 'Logs' / 'org.testbed.simple-app'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) - - def test_simple_as_file_in_module(self): - """When a simple app is started as `python app.py` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `app.py` - output = subprocess.check_output( - [sys.executable, "app.py"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_module(self): - """When a simple app is started as `python -m app` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "app"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_file(self): - "When a simple app is started as `python simple/app.py`, the app path is the folder holding app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "simple/app.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_module(self): - "When a simple app is started as `python -m simple`, the app path is the folder hodling app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "-m", "simple"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output) - - def test_installed_as_module(self): - "When the installed app is started, the app path is the folder holding app.py" - # Spawn the installed testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "installed"], - cwd=TESTBED_PATH, - text=True, - ) - - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH / 'installed'}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / 'Library' / 'Application Support' / 'org.testbed.installed'}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / 'Library' / 'Caches' / 'org.testbed.installed'}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / 'Library' / 'Logs' / 'org.testbed.installed'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) diff --git a/core/MANIFEST.in b/core/MANIFEST.in index 07777e3f2c..ecd14299d5 100644 --- a/core/MANIFEST.in +++ b/core/MANIFEST.in @@ -2,5 +2,3 @@ include CONTRIBUTING.md include LICENSE include README.rst recursive-include tests *.py -include tests/testbed/installed.dist-info/INSTALLER -include tests/testbed/installed.dist-info/METADATA diff --git a/core/src/toga/app.py b/core/src/toga/app.py index ecd64235ea..13360bb9ab 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -9,6 +9,7 @@ from toga.command import CommandSet from toga.handlers import wrapped_handler from toga.icons import Icon +from toga.paths import Paths from toga.platform import get_platform_factory from toga.widgets.base import WidgetRegistry from toga.window import Window @@ -317,9 +318,11 @@ def __init__( # Set the application DOM ID; create an ID if one hasn't been provided. self._id = id if id else identifier(self) - # Get a platform factory, and a paths instance from the factory. + # Get a platform factory. self.factory = get_platform_factory() - self._paths = self.factory.paths + + # Instantiate the paths instance for this app. + self._paths = Paths() # If an icon (or icon name) has been explicitly provided, use it; # otherwise, the icon will be based on the app name. @@ -348,7 +351,7 @@ def _create_impl(self): return self.factory.App(interface=self) @property - def paths(self): + def paths(self) -> Paths: """Paths for platform appropriate locations on the user's file system. Some platforms do not allow arbitrary file access to any location on @@ -358,26 +361,6 @@ def paths(self): The ``paths`` object has a set of sub-properties that return ``pathlib.Path`` instances of platform-appropriate paths on the file system. - - :PROPERTIES: - * **app** – The directory containing the app's ``__main__`` module. - This location should be considered read-only, and only used to - retrieve app-specific resources (e.g., resource files bundled with - the app). - - * **data** – Platform-appropriate location for user data (e.g., user - settings) - - * **cache** – Platform-appropriate location for temporary files. It - should be assumed that the operating system will purge the - contents of this directory without warning if it needs to recover - disk space. - - * **logs** – Platform-appropriate location for log files for this - app. - - * **toga** – The path of the ``toga`` core module. This location - should be considered read-only. """ return self._paths diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index b512e581f2..955a857a63 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -1,6 +1,7 @@ import os import warnings +import toga from toga.platform import get_platform_factory @@ -49,9 +50,9 @@ def __init__(self, path, system=False): self.factory = get_platform_factory() try: if self.system: - resource_path = self.factory.paths.toga + resource_path = toga.App.app.paths.toga else: - resource_path = self.factory.paths.app + resource_path = toga.App.app.paths.app if self.factory.Icon.SIZES: full_path = { diff --git a/core/src/toga/images.py b/core/src/toga/images.py index 003f42e662..42705891f6 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -1,6 +1,7 @@ import pathlib import warnings +import toga from toga.platform import get_platform_factory @@ -35,7 +36,7 @@ def __init__(self, path=None, *, data=None): if self.data is not None: self._impl = self.factory.Image(interface=self, data=self.data) elif isinstance(self.path, pathlib.Path): - full_path = self.factory.paths.app / self.path + full_path = toga.App.app.paths.app / self.path if not full_path.exists(): raise FileNotFoundError( "Image file {full_path!r} does not exist".format( diff --git a/core/src/toga/paths.py b/core/src/toga/paths.py new file mode 100644 index 0000000000..a8c38301dd --- /dev/null +++ b/core/src/toga/paths.py @@ -0,0 +1,67 @@ +import importlib +import sys +from pathlib import Path + +import toga +from toga.platform import get_platform_factory + + +class Paths: + def __init__(self): + self.factory = get_platform_factory() + self._impl = self.factory.Paths(self) + + @property + def toga(self) -> Path: + """The path that contains the core Toga resources. + + This path should be considered read-only. You should not attempt to write + files into this path. + """ + return Path(toga.__file__).parent + + @property + def app(self) -> Path: + """The path of the folder that contains the definition of the app class. + + This path should be considered read-only. You should not attempt to write + files into this path. + """ + try: + return Path(importlib.util.find_spec(toga.App.app.__module__).origin).parent + except ValueError: + # When running a single file `python path/to/myapp.py`, the app + # won't have a module because it's the mainline. Default to the + # path that contains the myapp.py. + return Path(sys.argv[0]).absolute().parent + + @property + def config(self) -> Path: + """The platform-appropriate location for storing user configuration + files associated with this app. + """ + return self._impl.get_config_path() + + @property + def data(self) -> Path: + """The platform-appropriate location for storing user data associated + with this app. + """ + return self._impl.get_data_path() + + @property + def cache(self) -> Path: + """The platform-appropriate location for storing cache files associated + with this app. + + It should be assumed that the operating system will purge the contents + of this directory without warning if it needs to recover disk space. + """ + return self._impl.get_cache_path() + + @property + def logs(self) -> Path: + """The platform-appropriate location for storing log files associated + with this app. + """ + return self._impl.get_logs_path() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 17227e56dd..c35818bd24 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -1,5 +1,4 @@ import toga -import toga_dummy from toga.fonts import SANS_SERIF from toga_dummy.utils import TestCase @@ -64,7 +63,7 @@ def test_icon(self): self.assertNotEqual(widget.factory, self.factory) def test_image(self): - resource_path = toga_dummy.factory.paths.toga + resource_path = toga.App.app.paths.toga image = toga.Image(resource_path / "resources/toga.png") with self.assertWarns(DeprecationWarning): image.bind(factory=self.factory) diff --git a/core/tests/test_paths.py b/core/tests/test_paths.py index 85972596d9..8755ee1113 100644 --- a/core/tests/test_paths.py +++ b/core/tests/test_paths.py @@ -1,160 +1,126 @@ +import os import subprocess import sys from pathlib import Path import toga -from toga_dummy.utils import TestCase - - -class TestPaths(TestCase): - def setUp(self): - super().setUp() - # We use the existence of a __main__ module as a proxy for being in test - # conditions. This isn't *great*, but the __main__ module isn't meaningful - # during tests, and removing it allows us to avoid having explicit "if - # under test conditions" checks in paths.py. - if "__main__" in sys.modules: - del sys.modules["__main__"] - - def assert_paths(self, output, app_path, app_name): - "Assert the paths for the standalone app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={app_path.resolve()}", - results, - ) - self.assertIn( - f"app.paths.data={(Path.home() / 'user_data' / f'org.testbed.{app_name}').resolve()}", - results, - ) - self.assertIn( - f"app.paths.cache={(Path.home() / 'cache' / f'org.testbed.{app_name}').resolve()}", - results, - ) - self.assertIn( - f"app.paths.logs={(Path.home() / 'logs' / f'org.testbed.{app_name}').resolve()}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent.resolve()}", - results, - ) - - def test_as_test(self): - "During test conditions, the app path is the current working directory" - app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - ) - - self.assertEqual( - app.paths.app, - Path.cwd(), - ) - self.assertEqual( - app.paths.data, - Path.home() / "user_data" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.cache, - Path.home() / "cache" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.logs, - Path.home() / "logs" / "org.beeware.test-app", - ) - self.assertEqual( - app.paths.toga, - Path(toga.__file__).parent, - ) - - def test_as_interactive(self): - "At an interactive prompt, the app path is the current working directory" - # Spawn the standalone app using the interactive-mode mocking entry point - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "standalone.py", "--backend:dummy", "--interactive"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd, app_name="standalone-app") - - def test_as_file(self): - "When started as `python app.py`, the app path is the folder holding app.py" - # Spawn the standalone app using `standalone.py` - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "standalone.py", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd, app_name="standalone-app") - - def test_as_module(self): - "When started as `python -m app`, the app path is the folder holding app.py" - # Spawn the standalone app app using `-m standalone` - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "-m", "standalone", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd, app_name="standalone-app") - - def test_simple_as_file_in_module(self): - """When a simple app is started as `python app.py` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `app.py` - cwd = Path(__file__).parent / "testbed" / "simple" - output = subprocess.check_output( - [sys.executable, "app.py", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd, app_name="simple-app") - - def test_simple_as_module(self): - """When a simple apps is started as `python -m app` inside a runnable module, - the app path is the folder holding app.py.""" - # Spawn the simple testbed app using `-m app` - cwd = Path(__file__).parent / "testbed" / "simple" - output = subprocess.check_output( - [sys.executable, "-m", "app", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd, app_name="simple-app") - - def test_simple_as_deep_file(self): - "When a simple app is started as `python simple/app.py`, the app path is the folder holding app.py" - # Spawn the simple testbed app using `-m simple` - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "simple/app.py", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd / "simple", app_name="simple-app") - - def test_simple_as_deep_module(self): - "When a simple app is started as `python -m simple`, the app path is the folder holding app.py" - # Spawn the simple testbed app using `-m simple` - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "-m", "simple", "--backend:dummy"], - cwd=cwd, - text=True, - ) - self.assert_paths(output, app_path=cwd / "simple", app_name="simple-app") - - def test_installed_as_module(self): - "When the installed app is started, the app path is the folder holding app.py" - # Spawn the installed testbed app using `-m app` - cwd = Path(__file__).parent / "testbed" - output = subprocess.check_output( - [sys.executable, "-m", "installed", "--backend:dummy"], - cwd=cwd, - text=True, - ) - - self.assert_paths(output, app_path=cwd / "installed", app_name="installed") + + +def run_app(args, cwd): + "Run a Toga app as a subprocess with coverage enabled and the Toga Dummy backend" + output = subprocess.check_output( + [sys.executable] + args, + cwd=cwd, + env={ + "COVERAGE_PROCESS_START": str( + Path(__file__).parent.parent / "pyproject.toml" + ), + "PYTHONPATH": str(Path(__file__).parent / "testbed" / "customize"), + "TOGA_BACKEND": "toga_dummy", + }, + text=True, + ) + # When called as a subprocess, coverage drops it's coverage report in CWD. + # Move it to the projet root for combination with the main test report. + for file in cwd.glob(".coverage*"): + os.rename(file, Path(__file__).parent.parent / file.name) + return output + + +def assert_paths(output, app_path, app_name): + "Assert the paths for the standalone app are consistent" + results = output.splitlines() + assert f"app.paths.app={app_path.resolve()}" in results + assert ( + f"app.paths.config={(Path.home() / 'config' / f'org.testbed.{app_name}').resolve()}" + in results + ) + assert ( + f"app.paths.data={(Path.home() / 'user_data' / f'org.testbed.{app_name}').resolve()}" + in results + ) + assert ( + f"app.paths.cache={(Path.home() / 'cache' / f'org.testbed.{app_name}').resolve()}" + in results + ) + assert ( + f"app.paths.logs={(Path.home() / 'logs' / f'org.testbed.{app_name}').resolve()}" + in results + ) + assert f"app.paths.toga={Path(toga.__file__).parent.resolve()}" in results + + +def test_as_interactive(): + "At an interactive prompt, the app path is the current working directory" + # Spawn the interactive-mode mocking entry point + cwd = Path(__file__).parent / "testbed" + output = run_app(["interactive.py"], cwd=cwd) + assert_paths(output, app_path=cwd, app_name="interactive-app") + + +def test_simple_as_file_in_module(): + """When a simple app is started as `python app.py` inside a runnable module, the + app path is the folder holding app.py.""" + # Spawn the simple testbed app using `app.py` + cwd = Path(__file__).parent / "testbed" / "simple" + output = run_app(["app.py"], cwd=cwd) + assert_paths(output, app_path=Path(toga.__file__).parent, app_name="simple-app") + + +def test_simple_as_module(): + """When a simple apps is started as `python -m app` inside a runnable module, + the app path is the folder holding app.py.""" + # Spawn the simple testbed app using `-m app` + cwd = Path(__file__).parent / "testbed" / "simple" + output = run_app(["-m", "app"], cwd=cwd) + assert_paths(output, app_path=Path(toga.__file__).parent, app_name="simple-app") + + +def test_simple_as_deep_file(): + "When a simple app is started as `python simple/app.py`, the app path is the folder holding app.py" + # Spawn the simple testbed app using `simple/app.py` + cwd = Path(__file__).parent / "testbed" + output = run_app(["simple/app.py"], cwd=cwd) + assert_paths(output, app_path=Path(toga.__file__).parent, app_name="simple-app") + + +def test_simple_as_deep_module(): + "When a simple app is started as `python -m simple`, the app path is the folder holding app.py" + # Spawn the simple testbed app using `-m simple` + cwd = Path(__file__).parent / "testbed" + output = run_app(["-m", "simple"], cwd=cwd) + assert_paths(output, app_path=Path(toga.__file__).parent, app_name="simple-app") + + +def test_subclassed_as_file_in_module(): + """When a subclassed app is started as `python app.py` inside a runnable module, the + app path is the folder holding app.py.""" + # Spawn the simple testbed app using `app.py` + cwd = Path(__file__).parent / "testbed" / "subclassed" + output = run_app(["app.py"], cwd=cwd) + assert_paths(output, app_path=cwd, app_name="subclassed-app") + + +def test_subclassed_as_module(): + """When a subclassed app is started as `python -m app` inside a runnable module, + the app path is the folder holding app.py.""" + # Spawn the subclassed testbed app using `-m app` + cwd = Path(__file__).parent / "testbed" / "subclassed" + output = run_app(["-m", "app"], cwd=cwd) + assert_paths(output, app_path=cwd, app_name="subclassed-app") + + +def test_subclassed_as_deep_file(): + "When a subclassed app is started as `python simple/app.py`, the app path is the folder holding app.py" + # Spawn the subclassed testbed app using `subclassed/app.py` + cwd = Path(__file__).parent / "testbed" + output = run_app(["subclassed/app.py"], cwd=cwd) + assert_paths(output, app_path=cwd / "subclassed", app_name="subclassed-app") + + +def test_subclassed_as_deep_module(): + "When a subclassed app is started as `python -m simple`, the app path is the folder holding app.py" + # Spawn the subclassed testbed app using `-m subclassed` + cwd = Path(__file__).parent / "testbed" + output = run_app(["-m", "subclassed"], cwd=cwd) + assert_paths(output, app_path=cwd / "subclassed", app_name="subclassed-app") diff --git a/core/tests/testbed/bootstrap.py b/core/tests/testbed/bootstrap.py deleted file mode 100644 index da6436d105..0000000000 --- a/core/tests/testbed/bootstrap.py +++ /dev/null @@ -1,11 +0,0 @@ -import importlib -import sys - -# If the user provided a --app: argument, -# import that module as the app. -app = [arg.split(":")[1] for arg in sys.argv if arg.startswith("--app:")] -main = importlib.import_module(f"{app[0]}.app").main - - -if __name__ == "__main__": - main() diff --git a/core/tests/testbed/customize/sitecustomize.py b/core/tests/testbed/customize/sitecustomize.py new file mode 100644 index 0000000000..6a1603a850 --- /dev/null +++ b/core/tests/testbed/customize/sitecustomize.py @@ -0,0 +1,3 @@ +import coverage + +cov = coverage.process_startup() diff --git a/core/tests/testbed/installed.dist-info/INSTALLER b/core/tests/testbed/installed.dist-info/INSTALLER deleted file mode 100644 index 0d8da94db0..0000000000 --- a/core/tests/testbed/installed.dist-info/INSTALLER +++ /dev/null @@ -1 +0,0 @@ -briefcase diff --git a/core/tests/testbed/installed.dist-info/METADATA b/core/tests/testbed/installed.dist-info/METADATA deleted file mode 100644 index 3dcc678c1a..0000000000 --- a/core/tests/testbed/installed.dist-info/METADATA +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 2.1 -Briefcase-Version: 0.3.8 -Name: installed -Formal-Name: Installed App -App-ID: org.testbed.installed -Version: 1.2.3 -Home-page: https://beeware.org -Author: Tiberius Yak -Author-email: tiberius@beeware.org -Summary: A testing app diff --git a/core/tests/testbed/installed/__init__.py b/core/tests/testbed/installed/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/core/tests/testbed/installed/__main__.py b/core/tests/testbed/installed/__main__.py deleted file mode 100644 index 0421383aac..0000000000 --- a/core/tests/testbed/installed/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from installed.app import main - -if __name__ == "__main__": - main() diff --git a/core/tests/testbed/installed/app.py b/core/tests/testbed/installed/app.py deleted file mode 100644 index 006319cbed..0000000000 --- a/core/tests/testbed/installed/app.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys - -import toga -from toga import platform - -# If the user provided a --backend: argument, -# use that backend as the factory. -backend = [arg.split(":")[1] for arg in sys.argv if arg.startswith("--backend:")] -try: - platform.current_platform = backend[0] -except IndexError: - pass - - -def main(): - app = toga.App() - - print(f"app.paths.app={app.paths.app.resolve()}") - print(f"app.paths.data={app.paths.data.resolve()}") - print(f"app.paths.cache={app.paths.cache.resolve()}") - print(f"app.paths.logs={app.paths.logs.resolve()}") - print(f"app.paths.toga={app.paths.toga.resolve()}") - - -if __name__ == "__main__": - main() diff --git a/core/tests/testbed/standalone.py b/core/tests/testbed/interactive.py similarity index 56% rename from core/tests/testbed/standalone.py rename to core/tests/testbed/interactive.py index c6cf4e4852..9ddbb8cdb9 100644 --- a/core/tests/testbed/standalone.py +++ b/core/tests/testbed/interactive.py @@ -4,25 +4,20 @@ # Before we import toga, check for the --interactive flag # If that flag exists, mock the behavior of an interactive shell # - replace the __main__ module with an in-memory representation. -if "--interactive" in sys.argv: - sys.modules["__main__"] = types.ModuleType("__main__") - -import toga -from toga import platform - -# If the user provided a --backend: argument, -# use that backend as the factory. -backend = [arg.split(":")[1] for arg in sys.argv if arg.startswith("--backend:")] -try: - platform.current_platform = backend[0] -except IndexError: +sys.modules["__main__"] = types.ModuleType("__main__") + +import toga # noqa: E402 + + +class SubclassedApp(toga.App): pass def main(): - app = toga.App("Standalone App", "org.testbed.standalone-app") + app = SubclassedApp("Interactive App", "org.testbed.interactive-app") print(f"app.paths.app={app.paths.app.resolve()}") + print(f"app.paths.config={app.paths.config.resolve()}") print(f"app.paths.data={app.paths.data.resolve()}") print(f"app.paths.cache={app.paths.cache.resolve()}") print(f"app.paths.logs={app.paths.logs.resolve()}") diff --git a/core/tests/testbed/simple/app.py b/core/tests/testbed/simple/app.py index 22cd334495..be558b3dbc 100644 --- a/core/tests/testbed/simple/app.py +++ b/core/tests/testbed/simple/app.py @@ -1,21 +1,11 @@ -import sys - import toga -from toga import platform - -# If the user provided a --backend: argument, -# use that backend as the factory. -backend = [arg.split(":")[1] for arg in sys.argv if arg.startswith("--backend:")] -try: - platform.current_platform = backend[0] -except IndexError: - pass def main(): app = toga.App("Simple App", "org.testbed.simple-app") print(f"app.paths.app={app.paths.app.resolve()}") + print(f"app.paths.config={app.paths.config.resolve()}") print(f"app.paths.data={app.paths.data.resolve()}") print(f"app.paths.cache={app.paths.cache.resolve()}") print(f"app.paths.logs={app.paths.logs.resolve()}") diff --git a/core/tests/testbed/subclassed/app.py b/core/tests/testbed/subclassed/app.py index 5cd7190574..971bf8842c 100644 --- a/core/tests/testbed/subclassed/app.py +++ b/core/tests/testbed/subclassed/app.py @@ -1,15 +1,4 @@ -import sys - import toga -from toga import platform - -# If the user provided a --backend: argument, -# use that backend as the factory. -backend = [arg.split(":")[1] for arg in sys.argv if arg.startswith("--backend:")] -try: - platform.current_platform = backend[0] -except IndexError: - pass class SubclassedApp(toga.App): @@ -20,6 +9,7 @@ def main(): app = SubclassedApp("Subclassed App", "org.testbed.subclassed-app") print(f"app.paths.app={app.paths.app.resolve()}") + print(f"app.paths.config={app.paths.config.resolve()}") print(f"app.paths.data={app.paths.data.resolve()}") print(f"app.paths.cache={app.paths.cache.resolve()}") print(f"app.paths.logs={app.paths.logs.resolve()}") diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 08db6dfe71..33ca3d17a0 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -8,4 +8,5 @@ Resources group icons images + paths validators diff --git a/docs/reference/api/resources/paths.rst b/docs/reference/api/resources/paths.rst new file mode 100644 index 0000000000..e044b3c3e6 --- /dev/null +++ b/docs/reference/api/resources/paths.rst @@ -0,0 +1,52 @@ +App Paths +========== + +A mechanism for obtaining platform-appropriate file system locations for an +application. + +.. rst-class:: widget-support +.. csv-filter:: Availability (:ref:`Key `) + :header-rows: 1 + :file: ../../data/widgets_by_platform.csv + :included_cols: 4,5,6,7,8,9 + :exclude: {0: '(?!(App Paths|Component)$)'} + +Usage +----- + +When Python code executes from the command line, the working directory is a +known location - the location where the application was started. However, when +building GUI apps, there is no "working directory". As a result, when specifying +file paths, relative paths cannot be used, as there is no location to which they +can be considered relative. + +Complicating matters further, operating systems have conventions over where +certain file types should be stored. For example, macOS provides the +``~/Library/Application Support`` folder; Linux encourages use of the +``~/.config`` folder (amongst others), and Windows provides the +``AppData/Local`` folder. + +To assist with finding an appropriate location to store application files, every +Toga application has a ``paths`` object that provides known file system +locations that are appropriate for storing files of given types, such as +configuration files, log files, cache files, or user documents. + +Each location provided by the ``paths`` object is a :class:`Pathlib.Path` that +can be used to construct a full file path. If required, additional sub-folders +can be created under these locations. + +The paths returned are *not* guaranteed to be empty or unique. For example, you +should not assume that the user data location *only* contains user data files. +Depending on platform conventions, there may be other files or folders. + +You should not assume that any of these paths already exist. The location is +guaranteed to follow operating system conventions, but the application is +responsible for ensuring the folder exists prior to writing files in these +locations. + +Reference +--------- + +.. autoclass:: toga.paths.Paths + :members: + :undoc-members: diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index af4795e225..be4c46e0a8 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -32,4 +32,5 @@ Command,Resource,:class:`~toga.command.Command`,Command,|b|,|b|,|b|,,|b|, Group,Resource,:class:`~toga.command.Group`,Command group,|b|,|b|,|b|,|b|,|b|, Icon,Resource,:class:`~toga.icons.Icon`,"An icon for buttons, menus, etc",|b|,|b|,|b|,|b|,|b|, Image,Resource,:class:`~toga.images.Image`,An image,|b|,|b|,|b|,|b|,|b|, +App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, Validators,Resource,:ref:`Validators `,Input validators,|y|,|y|,|y|,|y|,|y|, diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 9e72c8ed0f..cb7ff2b70b 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -5,7 +5,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths from .widgets.activityindicator import ActivityIndicator from .widgets.base import Widget from .widgets.box import Box @@ -48,7 +48,7 @@ def not_implemented(feature): "Font", "Icon", "Image", - "paths", + "Paths", "dialogs", "ActivityIndicator", "Box", diff --git a/dummy/src/toga_dummy/paths.py b/dummy/src/toga_dummy/paths.py index 7a26cadcf8..45cc69089a 100644 --- a/dummy/src/toga_dummy/paths.py +++ b/dummy/src/toga_dummy/paths.py @@ -1,43 +1,20 @@ -import sys from pathlib import Path -import toga from toga import App class Paths: - # Allow instantiating Path object via the factory - Path = Path + def __init__(self, interface): + self.interface = interface - @property - def app(self): - try: - return Path(sys.modules["__main__"].__file__).parent - except KeyError: - # If we're running in a test suite, - # there isn't a __main__ module - return Path.cwd() - except AttributeError: - # If we're running at an interactive prompt, - # the __main__ module isn't file-based. - return Path.cwd() + def get_config_path(self): + return Path.home() / "config" / App.app.app_id - @property - def data(self): + def get_data_path(self): return Path.home() / "user_data" / App.app.app_id - @property - def cache(self): + def get_cache_path(self): return Path.home() / "cache" / App.app.app_id - @property - def logs(self): + def get_logs_path(self): return Path.home() / "logs" / App.app.app_id - - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent - - -paths = Paths() diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index 1a0ffec79b..ad4d0ce0a0 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -5,7 +5,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button @@ -46,7 +46,7 @@ def not_implemented(feature): "Font", "Icon", "Image", - "paths", + "Paths", "dialogs", # Widgets "ActivityIndicator", diff --git a/gtk/src/toga_gtk/paths.py b/gtk/src/toga_gtk/paths.py index e325b31bac..a946f9170a 100644 --- a/gtk/src/toga_gtk/paths.py +++ b/gtk/src/toga_gtk/paths.py @@ -1,43 +1,20 @@ -import sys from pathlib import Path -import toga from toga import App class Paths: - # Allow instantiating Path object via the factory - Path = Path + def __init__(self, interface): + self.interface = interface - @property - def app(self): - try: - return Path(sys.modules["__main__"].__file__).parent - except KeyError: - # If we're running in test conditions, - # there is no __main__ module. - return Path.cwd() - except AttributeError: - # If we're running at an interactive prompt, - # the __main__ module isn't file-based. - return Path.cwd() + def get_config_path(self): + return Path.home() / ".config" / App.app.app_name - @property - def data(self): + def get_data_path(self): return Path.home() / ".local" / "share" / App.app.app_name - @property - def cache(self): + def get_cache_path(self): return Path.home() / ".cache" / App.app.app_name - @property - def logs(self): + def get_logs_path(self): return Path.home() / ".cache" / App.app.app_name / "log" - - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent - - -paths = Paths() diff --git a/gtk/tests/test_paths.py b/gtk/tests/test_paths.py deleted file mode 100644 index 656e0ba092..0000000000 --- a/gtk/tests/test_paths.py +++ /dev/null @@ -1,198 +0,0 @@ -import subprocess -import sys -import unittest -from pathlib import Path - -import toga - -TESTBED_PATH = Path(__file__).parent.parent.parent / "core" / "tests" / "testbed" - - -class TestPaths(unittest.TestCase): - def setUp(self): - # We use the existence of a __main__ module as a proxy for being in test - # conditions. This isn't *great*, but the __main__ module isn't meaningful - # during tests, and removing it allows us to avoid having explicit "if - # under test conditions" checks in paths.py. - if "__main__" in sys.modules: - del sys.modules["__main__"] - - def test_as_test(self): - "During test conditions, the app path is the current working directory" - app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - author="Jane Developer", - ) - - self.assertEqual( - app.paths.app, - Path.cwd(), - ) - self.assertEqual( - app.paths.data, - Path.home() / ".local" / "share" / "toga", - ) - self.assertEqual( - app.paths.cache, - Path.home() / ".cache" / "toga", - ) - self.assertEqual( - app.paths.logs, - Path.home() / ".cache" / "toga" / "log", - ) - self.assertEqual( - app.paths.toga, - Path(toga.__file__).parent, - ) - - def assert_standalone_paths(self, output): - "Assert the paths for the standalone app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / '.local' / 'share' / 'toga'}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / '.cache' / 'toga'}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / '.cache' / 'toga' / 'log'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) - - def test_as_interactive(self): - "At an interactive prompt, the app path is the current working directory" - # Spawn the standalone app using the interactive-mode mocking entry point - output = subprocess.check_output( - [sys.executable, "standalone.py", "--interactive"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_file(self): - "When started as `python app.py`, the app path is the folder holding app.py" - # Spawn the standalone app using `app.py` - output = subprocess.check_output( - [sys.executable, "standalone.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_module(self): - "When started as `python -m app`, the app path is the folder holding app.py" - # Spawn the standalone app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "standalone"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def assert_simple_paths(self, output, module_name="toga"): - "Assert the paths for the simple app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH / 'simple'}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / '.local' / 'share' / module_name}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / '.cache' / module_name}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / '.cache' / module_name / 'log'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) - - def test_simple_as_file_in_module(self): - """When a simple app is started as `python app.py` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `app.py` - output = subprocess.check_output( - [sys.executable, "app.py"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_module(self): - """When a simple app is started as `python -m app` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "app"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_file(self): - "When a simple app is started as `python simple/app.py`, the app path is the folder holding app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "simple/app.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_module(self): - "When a simple app is started as `python -m simple`, the app path is the folder hodling app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "-m", "simple"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output, module_name="simple") - - def test_installed_as_module(self): - "When the installed app is started, the app path is the folder holding app.py" - # Spawn the installed testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "installed"], - cwd=TESTBED_PATH, - text=True, - ) - - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH / 'installed'}", - results, - ) - self.assertIn( - f"app.paths.data={Path.home() / '.local' / 'share' / 'installed'}", - results, - ) - self.assertIn( - f"app.paths.cache={Path.home() / '.cache' / 'installed'}", - results, - ) - self.assertIn( - f"app.paths.logs={Path.home() / '.cache' / 'installed' / 'log'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent}", - results, - ) diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index 56bc2923a8..53278a1971 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -5,7 +5,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -47,7 +47,7 @@ def not_implemented(feature): "Font", "Icon", "Image", - "paths", + "Paths", "dialogs", # Widgets "Box", diff --git a/iOS/src/toga_iOS/paths.py b/iOS/src/toga_iOS/paths.py index 0f68fa7052..55e680a550 100644 --- a/iOS/src/toga_iOS/paths.py +++ b/iOS/src/toga_iOS/paths.py @@ -1,43 +1,18 @@ -import sys from pathlib import Path -import toga -from toga import App - class Paths: - # Allow instantiating Path object via the factory - Path = Path - - @property - def app(self): - try: - return Path(sys.modules["__main__"].__file__).parent - except KeyError: - # If we're running in test conditions, - # there is no __main__ module. - return Path.cwd() - except AttributeError: - # If we're running at an interactive prompt, - # the __main__ module isn't file-based. - return Path.cwd() - - @property - def data(self): - return Path.home() / "Library" / "Application Support" / App.app.app_id - - @property - def cache(self): - return Path.home() / "Library" / "Caches" / App.app.app_id + def __init__(self, interface): + self.interface = interface - @property - def logs(self): - return Path.home() / "Library" / "Logs" / App.app.app_id + def get_config_paths(self): + return Path.home() / "Library" / "Application support" / "Config" - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent + def get_data_paths(self): + return Path.home() / "Documents" + def get_cache_paths(self): + return Path.home() / "Library" / "Caches" -paths = Paths() + def get_logs_paths(self): + return Path.home() / "Library" / "Application support" / "Logs" diff --git a/web/src/toga_web/factory.py b/web/src/toga_web/factory.py index 17166c75c0..d563f14993 100644 --- a/web/src/toga_web/factory.py +++ b/web/src/toga_web/factory.py @@ -7,7 +7,7 @@ from .icons import Icon # from .images import Image -from .paths import paths +from .paths import Paths from .widgets.box import Box from .widgets.button import Button @@ -50,7 +50,7 @@ def not_implemented(feature): # 'Font', "Icon", # 'Image', - "paths", + "Paths", "dialogs", # # Widgets "Box", diff --git a/web/src/toga_web/paths.py b/web/src/toga_web/paths.py index 771486fe35..f68a5cd338 100644 --- a/web/src/toga_web/paths.py +++ b/web/src/toga_web/paths.py @@ -1,35 +1,21 @@ from pathlib import Path -import toga -from toga import App - class Paths: - # Allow instantiating Path object via the factory - Path = Path - - @property - def app(self): - # FIXME: None of the paths are right in a web context. - # return Path(__main__.__file__).parent - return Path("/") - - @property - def data(self): - return Path.home() / ".local" / "share" / App.app.name - - @property - def cache(self): - return Path.home() / ".cache" / App.app.name - - @property - def logs(self): - return Path.home() / ".cache" / App.app.name / "log" - - @property - def toga(self): - """Return a path to a Toga resources.""" - return Path(toga.__file__).parent + def __init__(self, interface): + self.interface = interface + + def get_config_path(self): + return Path.home() / "config" + + def get_data_path(self): + return Path.home() / "data" + + def get_cache_path(self): + return Path.home() / "cache" + + def get_logs_path(self): + return Path.home() / "log" paths = Paths() diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index f6f330a401..52eee11c08 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -4,7 +4,7 @@ from .fonts import Font from .icons import Icon from .images import Image -from .paths import paths +from .paths import Paths from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -44,7 +44,7 @@ def not_implemented(feature): "Font", "Icon", "Image", - "paths", + "Paths", "dialogs", # Widgets "Box", diff --git a/winforms/src/toga_winforms/paths.py b/winforms/src/toga_winforms/paths.py index b3569ca107..59eca590ba 100644 --- a/winforms/src/toga_winforms/paths.py +++ b/winforms/src/toga_winforms/paths.py @@ -1,26 +1,11 @@ -import sys from pathlib import Path -import toga from toga import App class Paths: - # Allow instantiating Path object via the factory - Path = Path - - @property - def app(self): - try: - return Path(sys.modules["__main__"].__file__).parent - except KeyError: - # If we're running in test conditions, - # there is no __main__ module. - return Path.cwd() - except AttributeError: - # If we're running at an interactive prompt, - # the __main__ module isn't file-based. - return Path.cwd() + def __init__(self, interface): + self.interface = interface @property def author(self): @@ -28,12 +13,20 @@ def author(self): return "Toga" return App.app.author - @property - def data(self): + def get_config_path(self): + return ( + Path.home() + / "AppData" + / "Local" + / self.author + / App.app.formal_name + / "Config" + ) + + def get_data_path(self): return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name - @property - def cache(self): + def get_cache_path(self): return ( Path.home() / "AppData" @@ -43,8 +36,7 @@ def cache(self): / "Cache" ) - @property - def logs(self): + def get_logs_path(self): return ( Path.home() / "AppData" @@ -53,11 +45,3 @@ def logs(self): / App.app.formal_name / "Logs" ) - - @property - def toga(self): - """Return a path to a Toga system resources.""" - return Path(toga.__file__).parent - - -paths = Paths() diff --git a/winforms/tests/test_paths.py b/winforms/tests/test_paths.py deleted file mode 100644 index de920dce17..0000000000 --- a/winforms/tests/test_paths.py +++ /dev/null @@ -1,207 +0,0 @@ -import subprocess -import sys -import unittest -from pathlib import Path - -import toga - -TESTBED_PATH = Path(__file__).parent.parent.parent / "core" / "tests" / "testbed" - - -class TestPaths(unittest.TestCase): - def setUp(self): - # We use the existence of a __main__ module as a proxy for being in test - # conditions. This isn't *great*, but the __main__ module isn't meaningful - # during tests, and removing it allows us to avoid having explicit "if - # under test conditions" checks in paths.py. - if "__main__" in sys.modules: - del sys.modules["__main__"] - - def test_as_test(self): - "During test conditions, the app path is the current working directory" - app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - author="Jane Developer", - ) - - self.assertEqual( - app.paths.app, - Path.cwd(), - ) - self.assertEqual( - app.paths.data, - Path.home() / "AppData" / "Local" / "Jane Developer" / "Test App", - ) - self.assertEqual( - app.paths.cache, - Path.home() / "AppData" / "Local" / "Jane Developer" / "Test App" / "Cache", - ) - self.assertEqual( - app.paths.logs, - Path.home() / "AppData" / "Local" / "Jane Developer" / "Test App" / "Logs", - ) - self.assertEqual( - app.paths.toga, - Path(toga.__file__).parent, - ) - - def assert_standalone_paths(self, output): - "Assert the paths for the standalone app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={TESTBED_PATH.resolve()}", - results, - ) - win_app_path = ( - Path.home() / "AppData" / "Local" / "Toga" / "Standalone App" - ).resolve() - self.assertIn( - f"app.paths.data={win_app_path}", - results, - ) - self.assertIn( - f"app.paths.cache={win_app_path / 'Cache'}", - results, - ) - self.assertIn( - f"app.paths.logs={win_app_path / 'Logs'}", - results, - ) - self.assertIn( - f"app.paths.toga={(Path(toga.__file__).parent).resolve()}", - results, - ) - - def test_as_interactive(self): - "At an interactive prompt, the app path is the current working directory" - # Spawn the standalone app using the interactive-mode mocking entry point - output = subprocess.check_output( - [sys.executable, "standalone.py", "--interactive"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_file(self): - "When started as `python app.py`, the app path is the folder holding app.py" - # Spawn the standalone app using `app.py` - output = subprocess.check_output( - [sys.executable, "standalone.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def test_as_module(self): - "When started as `python -m app`, the app path is the folder holding app.py" - # Spawn the standalone app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "standalone"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_standalone_paths(output) - - def assert_simple_paths(self, output): - "Assert the paths for the simple app are consistent" - results = output.splitlines() - self.assertIn( - f"app.paths.app={(TESTBED_PATH / 'simple').resolve()}", - results, - ) - win_app_path = ( - Path.home() / "AppData" / "Local" / "Toga" / "Simple App" - ).resolve() - self.assertIn( - f"app.paths.data={win_app_path}", - results, - ) - self.assertIn( - f"app.paths.cache={win_app_path / 'Cache'}", - results, - ) - self.assertIn( - f"app.paths.logs={win_app_path / 'Logs'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent.resolve()}", - results, - ) - - def test_simple_as_file_in_module(self): - """When a simple app is started as `python app.py` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `app.py` - output = subprocess.check_output( - [sys.executable, "app.py"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_module(self): - """When a simple app is started as `python -m app` inside a runnable module, the - app path is the folder holding app.py.""" - # Spawn the simple testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "app"], - cwd=TESTBED_PATH / "simple", - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_file(self): - "When a simple app is started as `python simple/app.py`, the app path is the folder holding app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "simple/app.py"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output) - - def test_simple_as_deep_module(self): - "When a simple app is started as `python -m simple`, the app path is the folder hodling app.py" - # Spawn the simple testbed app using `-m simple` - output = subprocess.check_output( - [sys.executable, "-m", "simple"], - cwd=TESTBED_PATH, - text=True, - ) - self.assert_simple_paths(output) - - def test_installed_as_module(self): - "When the installed app is started, the app path is the folder holding app.py" - # Spawn the installed testbed app using `-m app` - output = subprocess.check_output( - [sys.executable, "-m", "installed"], - cwd=TESTBED_PATH, - text=True, - ) - - results = output.splitlines() - self.assertIn( - f"app.paths.app={(TESTBED_PATH / 'installed').resolve()}", - results, - ) - win_app_path = ( - Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Installed App" - ).resolve() - self.assertIn( - f"app.paths.data={win_app_path}", - results, - ) - self.assertIn( - f"app.paths.cache={win_app_path / 'Cache'}", - results, - ) - self.assertIn( - f"app.paths.logs={win_app_path / 'Logs'}", - results, - ) - self.assertIn( - f"app.paths.toga={Path(toga.__file__).parent.resolve()}", - results, - ) From 9f3061e86b9894893640cf6c353879abb97993b6 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 6 Jun 2023 15:02:45 +0800 Subject: [PATCH 02/17] Add Changenote. --- changes/1964.feature.rst | 1 + changes/1964.removal.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 changes/1964.feature.rst create mode 100644 changes/1964.removal.rst diff --git a/changes/1964.feature.rst b/changes/1964.feature.rst new file mode 100644 index 0000000000..ecdf5b3422 --- /dev/null +++ b/changes/1964.feature.rst @@ -0,0 +1 @@ +The Paths property of apps now has 100% test coverage, and complete API documentation. diff --git a/changes/1964.removal.rst b/changes/1964.removal.rst new file mode 100644 index 0000000000..18bcffce45 --- /dev/null +++ b/changes/1964.removal.rst @@ -0,0 +1 @@ +The location returned by ``toga.App.paths.app`` is now the folder that contains the file that defines the app class used by the app. If you are using a ``toga.App`` instance directly, this may alter the path that is returned. From 525fe7f47b2a5044ede1e6a7ef881243b46ff8aa Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 6 Jun 2023 15:23:11 +0800 Subject: [PATCH 03/17] Use a full copy of the environment so that Windows gets SYSTEMROOT. --- core/tests/test_paths.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/core/tests/test_paths.py b/core/tests/test_paths.py index 8755ee1113..f98d3ea1f9 100644 --- a/core/tests/test_paths.py +++ b/core/tests/test_paths.py @@ -8,16 +8,22 @@ def run_app(args, cwd): "Run a Toga app as a subprocess with coverage enabled and the Toga Dummy backend" - output = subprocess.check_output( - [sys.executable] + args, - cwd=cwd, - env={ + # We need to do a full copy of the environment, then add our extra bits; + # if we don't the Windows interpreter won't inherit SYSTEMROOT + env = os.environ.copy() + env.update( + { "COVERAGE_PROCESS_START": str( Path(__file__).parent.parent / "pyproject.toml" ), "PYTHONPATH": str(Path(__file__).parent / "testbed" / "customize"), "TOGA_BACKEND": "toga_dummy", - }, + } + ) + output = subprocess.check_output( + [sys.executable] + args, + cwd=cwd, + env=env, text=True, ) # When called as a subprocess, coverage drops it's coverage report in CWD. From 7d2076ac7b13130511672de76ae39e0d922d347b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 6 Jun 2023 15:43:36 +0800 Subject: [PATCH 04/17] GTK and Winforms tweaks. --- gtk/tests/widgets/test_detailedlist.py | 2 ++ winforms/src/toga_winforms/fonts.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/gtk/tests/widgets/test_detailedlist.py b/gtk/tests/widgets/test_detailedlist.py index 003afbc077..50dab7a800 100644 --- a/gtk/tests/widgets/test_detailedlist.py +++ b/gtk/tests/widgets/test_detailedlist.py @@ -30,6 +30,8 @@ def handle_events(): ) class TestGtkDetailedList(unittest.TestCase): def setUp(self): + # An app needs to exist so that paths resolve. + _ = toga.App("Demo App", "org.beeware.demo") icon = toga.Icon( os.path.join( os.path.dirname(__file__), diff --git a/winforms/src/toga_winforms/fonts.py b/winforms/src/toga_winforms/fonts.py index d0427289f2..961a4e1a61 100644 --- a/winforms/src/toga_winforms/fonts.py +++ b/winforms/src/toga_winforms/fonts.py @@ -1,3 +1,4 @@ +import toga from toga.fonts import _REGISTERED_FONT_CACHE from toga_winforms.libs import WinFont, WinForms, win_font_family from toga_winforms.libs.fonts import win_font_size, win_font_style @@ -30,7 +31,7 @@ def __init__(self, interface): ) try: font_path = str( - self.interface.factory.paths.app / _REGISTERED_FONT_CACHE[font_key] + toga.App.app.paths.app / _REGISTERED_FONT_CACHE[font_key] ) try: self._pfc = PrivateFontCollection() From 7dc18a490c856791f26157df29398b78a6d030dc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 6 Jun 2023 15:53:49 +0800 Subject: [PATCH 05/17] Tweaks for iOS and Android. --- android/src/toga_android/fonts.py | 3 ++- iOS/src/toga_iOS/paths.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/fonts.py b/android/src/toga_android/fonts.py index 34ad2d3b8f..86e521648e 100644 --- a/android/src/toga_android/fonts.py +++ b/android/src/toga_android/fonts.py @@ -1,5 +1,6 @@ import os +import toga from toga.fonts import ( _REGISTERED_FONT_CACHE, BOLD, @@ -51,7 +52,7 @@ def apply(self, tv, default_size, default_typeface): ) if font_key in _REGISTERED_FONT_CACHE: font_path = str( - self.interface.factory.paths.app / _REGISTERED_FONT_CACHE[font_key] + toga.App.app.paths.app / _REGISTERED_FONT_CACHE[font_key] ) if os.path.isfile(font_path): typeface = Typeface.createFromFile(font_path) diff --git a/iOS/src/toga_iOS/paths.py b/iOS/src/toga_iOS/paths.py index 55e680a550..4c4d7d5d3e 100644 --- a/iOS/src/toga_iOS/paths.py +++ b/iOS/src/toga_iOS/paths.py @@ -5,14 +5,14 @@ class Paths: def __init__(self, interface): self.interface = interface - def get_config_paths(self): + def get_config_path(self): return Path.home() / "Library" / "Application support" / "Config" - def get_data_paths(self): + def get_data_path(self): return Path.home() / "Documents" - def get_cache_paths(self): + def get_cache_path(self): return Path.home() / "Library" / "Caches" - def get_logs_paths(self): + def get_logs_path(self): return Path.home() / "Library" / "Application support" / "Logs" From 3ed4d90793d9b6b05c87c2e11ec04fe0ae464b43 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 07:39:12 +0800 Subject: [PATCH 06/17] Add testbed coverage for cocoa paths. --- cocoa/tests_backend/app.py | 32 ++++++++++++++ cocoa/tests_backend/probe.py | 64 ++++++++++++++++++++++++++++ cocoa/tests_backend/widgets/base.py | 66 +++-------------------------- testbed/tests/conftest.py | 10 +++++ testbed/tests/test_paths.py | 29 +++++++++++++ 5 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 cocoa/tests_backend/app.py create mode 100644 cocoa/tests_backend/probe.py create mode 100644 testbed/tests/test_paths.py diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py new file mode 100644 index 0000000000..a9ee2337a5 --- /dev/null +++ b/cocoa/tests_backend/app.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from toga_cocoa.libs import NSApplication + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + def __init__(self, app): + super().__init__() + self.app = app + assert isinstance(self.app._impl.native, NSApplication) + + @property + def config_path(self): + return ( + Path.home() / "Library" / "Application Support" / "org.beeware.toga.testbed" + ) + + @property + def data_path(self): + return ( + Path.home() / "Library" / "Application Support" / "org.beeware.toga.testbed" + ) + + @property + def cache_path(self): + return Path.home() / "Library" / "Caches" / "org.beeware.toga.testbed" + + @property + def logs_path(self): + return Path.home() / "Library" / "Logs" / "org.beeware.toga.testbed" diff --git a/cocoa/tests_backend/probe.py b/cocoa/tests_backend/probe.py new file mode 100644 index 0000000000..adf88ce315 --- /dev/null +++ b/cocoa/tests_backend/probe.py @@ -0,0 +1,64 @@ +import asyncio +from ctypes import c_void_p + +from rubicon.objc import SEL, NSArray, NSObject, ObjCClass, objc_method +from rubicon.objc.api import NSString + +from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM +from toga_cocoa.libs.appkit import appkit + +NSRunLoop = ObjCClass("NSRunLoop") +NSRunLoop.declare_class_property("currentRunLoop") +NSDefaultRunLoopMode = NSString(c_void_p.in_dll(appkit, "NSDefaultRunLoopMode")) + + +class EventListener(NSObject): + @objc_method + def init(self): + self.event = asyncio.Event() + return self + + @objc_method + def onEvent(self): + self.event.set() + self.event.clear() + + +class BaseProbe: + def __init__(self): + self.event_listener = EventListener.alloc().init() + + async def post_event(self, event): + self.native.window.postEvent(event, atStart=False) + + # Add another event to the queue behind the original event, to notify us once + # it's been processed. + NSRunLoop.currentRunLoop.performSelector( + SEL("onEvent"), + target=self.event_listener, + argument=None, + order=0, + modes=NSArray.arrayWithObject(NSDefaultRunLoopMode), + ) + await self.event_listener.event.wait() + + def assert_font_family(self, expected): + assert self.font.family == { + CURSIVE: "Apple Chancery", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + SANS_SERIF: "Helvetica", + SERIF: "Times", + SYSTEM: ".AppleSystemUIFont", + }.get(expected, expected) + + async def redraw(self, message=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + if self.app.run_slow: + # If we're running slow, wait for a second + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(1) + else: + # Running at "normal" speed, we need to release to the event loop + # for at least one iteration. `runUntilDate:None` does this. + NSRunLoop.currentRunLoop.runUntilDate(None) diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 749a73d412..f28aedbf6a 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -1,56 +1,21 @@ -import asyncio -from ctypes import c_void_p - -from rubicon.objc import SEL, NSArray, NSObject, NSPoint, ObjCClass, objc_method -from rubicon.objc.api import NSString +from rubicon.objc import NSPoint from toga.colors import TRANSPARENT -from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM from toga_cocoa.libs import NSEvent, NSEventType -from toga_cocoa.libs.appkit import appkit +from ..probe import BaseProbe from .properties import toga_color -NSRunLoop = ObjCClass("NSRunLoop") -NSRunLoop.declare_class_property("currentRunLoop") -NSDefaultRunLoopMode = NSString(c_void_p.in_dll(appkit, "NSDefaultRunLoopMode")) - - -class EventListener(NSObject): - @objc_method - def init(self): - self.event = asyncio.Event() - return self - @objc_method - def onEvent(self): - self.event.set() - self.event.clear() - - -class SimpleProbe: +class SimpleProbe(BaseProbe): def __init__(self, widget): + super().__init__() + self.app = widget.app self.widget = widget self.impl = widget._impl self.native = widget._impl.native assert isinstance(self.native, self.native_class) - self.event_listener = EventListener.alloc().init() - - async def post_event(self, event): - self.native.window.postEvent(event, atStart=False) - - # Add another event to the queue behind the original event, to notify us once - # it's been processed. - NSRunLoop.currentRunLoop.performSelector( - SEL("onEvent"), - target=self.event_listener, - argument=None, - order=0, - modes=NSArray.arrayWithObject(NSDefaultRunLoopMode), - ) - await self.event_listener.event.wait() - def assert_container(self, container): container_native = container._impl.native for control in container_native.subviews: @@ -67,29 +32,12 @@ def assert_not_contained(self): def assert_alignment(self, expected): assert self.alignment == expected - def assert_font_family(self, expected): - assert self.font.family == { - CURSIVE: "Apple Chancery", - FANTASY: "Papyrus", - MONOSPACE: "Courier New", - SANS_SERIF: "Helvetica", - SERIF: "Times", - SYSTEM: ".AppleSystemUIFont", - }.get(expected, expected) - async def redraw(self, message=None): """Request a redraw of the app, waiting until that redraw has completed.""" - # Force a repaint + # Force a widget repaint self.widget.window.content._impl.native.displayIfNeeded() - if self.widget.app.run_slow: - # If we're running slow, wait for a second - print("Waiting for redraw" if message is None else message) - await asyncio.sleep(1) - else: - # Running at "normal" speed, we need to release to the event loop - # for at least one iteration. `runUntilDate:None` does this. - NSRunLoop.currentRunLoop.runUntilDate(None) + await super().redraw(message=message) @property def enabled(self): diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 00179b1e42..9b308a4409 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -1,6 +1,7 @@ import asyncio import inspect from dataclasses import dataclass +from importlib import import_module from pytest import fixture, register_assert_rewrite, skip @@ -24,6 +25,15 @@ def app(): return toga.App.app +@fixture +async def app_probe(app): + module = import_module("tests_backend.app") + probe = getattr(module, "AppProbe")(app) + + await probe.redraw(f"\nConstructing {app.__class__.__name__} probe") + yield probe + + @fixture(scope="session") def main_window(app): return app.main_window diff --git a/testbed/tests/test_paths.py b/testbed/tests/test_paths.py new file mode 100644 index 0000000000..f46bbc1523 --- /dev/null +++ b/testbed/tests/test_paths.py @@ -0,0 +1,29 @@ +import os +import shutil + +import pytest + + +@pytest.mark.parametrize("attr", ["config", "data", "cache", "logs"]) +async def test_app_paths(app, app_probe, attr): + """Platform paths are as expected.""" + path = getattr(app.paths, attr) + assert path == getattr(app_probe, f"{attr}_path") + + try: + # We can create a folder in the app path + tempdir = path / f"testbed-{os.getpid()}" + tempdir.mkdir(parents=True) + + # We can create a file in the app path + tempfile = tempdir / f"{attr}.txt" + with tempfile.open("w", encoding="utf-8") as f: + f.write(f"Hello {attr}\n") + + # We can create a file in the app path + with tempfile.open("r", encoding="utf-8") as f: + assert f.read() == f"Hello {attr}\n" + + finally: + if path.exists(): + shutil.rmtree(tempdir) From 69041f70413281c37ee66115c6fda943a16a7b77 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 08:42:14 +0800 Subject: [PATCH 07/17] Testbed coverage for iOS paths. --- iOS/src/toga_iOS/app.py | 7 ++++- iOS/src/toga_iOS/libs/foundation.py | 28 ++++++++++++++++++++ iOS/src/toga_iOS/paths.py | 17 +++++++++--- iOS/tests_backend/app.py | 40 +++++++++++++++++++++++++++++ iOS/tests_backend/probe.py | 27 +++++++++++++++++++ iOS/tests_backend/widgets/base.py | 20 +++++---------- testbed/tests/conftest.py | 3 ++- 7 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 iOS/tests_backend/app.py create mode 100644 iOS/tests_backend/probe.py diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 78cbd739f3..0ea6c19581 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -39,6 +39,7 @@ def application_didFinishLaunchingWithOptions_( self, application, launchOptions ) -> bool: print("App finished launching.") + App.app.native = application App.app.create() NSNotificationCenter.defaultCenter.addObserver( @@ -96,7 +97,11 @@ class App: def __init__(self, interface): self.interface = interface self.interface._impl = self - App.app = self # Add a reference for the PythonAppDelegate class to use. + # Native instance doesn't exist until the lifecycle completes. + self.native = None + + # Add a reference for the PythonAppDelegate class to use. + App.app = self asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() diff --git a/iOS/src/toga_iOS/libs/foundation.py b/iOS/src/toga_iOS/libs/foundation.py index bfb4826ba0..c6eb0446a0 100644 --- a/iOS/src/toga_iOS/libs/foundation.py +++ b/iOS/src/toga_iOS/libs/foundation.py @@ -2,6 +2,7 @@ # System/Library/Frameworks/Foundation.framework ########################################################################## from ctypes import c_bool, cdll, util +from enum import Enum from rubicon.objc import NSPoint, NSRect, ObjCClass @@ -43,6 +44,33 @@ NSNotificationCenter = ObjCClass("NSNotificationCenter") +###################################################################### +# NSFileManager.h +NSFileManager = ObjCClass("NSFileManager") + +###################################################################### +# NSPathUtilities.h + + +class NSSearchPathDirectory(Enum): + Documents = 9 + Cache = 13 + ApplicationSupport = 14 + Downloads = 15 + Movies = 17 + Music = 18 + Pictures = 19 + Public = 21 + + +class NSSearchPathDomainMask(Enum): + User = 1 + Local = 2 + Network = 4 + System = 8 + All = 0x0FFFF + + ###################################################################### # NSRunLoop.h NSRunLoop = ObjCClass("NSRunLoop") diff --git a/iOS/src/toga_iOS/paths.py b/iOS/src/toga_iOS/paths.py index 4c4d7d5d3e..168728a597 100644 --- a/iOS/src/toga_iOS/paths.py +++ b/iOS/src/toga_iOS/paths.py @@ -1,18 +1,27 @@ from pathlib import Path +from toga_iOS.libs import NSFileManager, NSSearchPathDirectory, NSSearchPathDomainMask + class Paths: def __init__(self, interface): self.interface = interface + def get_path(self, search_path): + file_manager = NSFileManager.defaultManager + urls = file_manager.URLsForDirectory( + search_path, inDomains=NSSearchPathDomainMask.User + ) + return Path(urls[0].path) + def get_config_path(self): - return Path.home() / "Library" / "Application support" / "Config" + return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Config" def get_data_path(self): - return Path.home() / "Documents" + return self.get_path(NSSearchPathDirectory.Documents) def get_cache_path(self): - return Path.home() / "Library" / "Caches" + return self.get_path(NSSearchPathDirectory.Cache) def get_logs_path(self): - return Path.home() / "Library" / "Application support" / "Logs" + return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs" diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py new file mode 100644 index 0000000000..ec8a355599 --- /dev/null +++ b/iOS/tests_backend/app.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from toga_iOS.libs import ( + NSFileManager, + NSSearchPathDirectory, + NSSearchPathDomainMask, + UIApplication, +) + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + def __init__(self, app): + super().__init__() + self.app = app + assert isinstance(self.app._impl.native, UIApplication) + + def get_path(self, search_path): + file_manager = NSFileManager.defaultManager + urls = file_manager.URLsForDirectory( + search_path, inDomains=NSSearchPathDomainMask.User + ) + return Path(urls[0].path) + + @property + def config_path(self): + return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Config" + + @property + def data_path(self): + return self.get_path(NSSearchPathDirectory.Documents) + + @property + def cache_path(self): + return self.get_path(NSSearchPathDirectory.Cache) + + @property + def logs_path(self): + return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs" diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py new file mode 100644 index 0000000000..c7e7dbde9b --- /dev/null +++ b/iOS/tests_backend/probe.py @@ -0,0 +1,27 @@ +import asyncio + +from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM +from toga_iOS.libs import NSRunLoop + + +class BaseProbe: + def assert_font_family(self, expected): + assert self.font.family == { + CURSIVE: "Apple Chancery", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + SANS_SERIF: "Helvetica", + SERIF: "Times New Roman", + SYSTEM: ".AppleSystemUIFont", + }.get(expected, expected) + + async def redraw(self, message=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # If we're running slow, wait for a second + if self.app.run_slow: + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(1) + else: + # Running at "normal" speed, we need to release to the event loop + # for at least one iteration. `runUntilDate:None` does this. + NSRunLoop.currentRunLoop.runUntilDate(None) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 559d70dec3..a8d3011069 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -1,8 +1,7 @@ -import asyncio - from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM -from toga_iOS.libs import NSRunLoop, UIApplication +from toga_iOS.libs import UIApplication +from ..probe import BaseProbe from .properties import toga_color # From UIControl.h @@ -33,8 +32,10 @@ UIControlEventAllEvents = 0xFFFFFFFF -class SimpleProbe: +class SimpleProbe(BaseProbe): def __init__(self, widget): + super().__init__() + self.app = widget.app self.widget = widget self.native = widget._impl.native assert isinstance(self.native, self.native_class) @@ -66,17 +67,10 @@ def assert_font_family(self, expected): async def redraw(self, message=None): """Request a redraw of the app, waiting until that redraw has completed.""" - # Force a repaint + # Force a widget repaint self.widget.window.content._impl.native.layer.displayIfNeeded() - # If we're running slow, wait for a second - if self.widget.app.run_slow: - print("Waiting for redraw" if message is None else message) - await asyncio.sleep(1) - else: - # Running at "normal" speed, we need to release to the event loop - # for at least one iteration. `runUntilDate:None` does this. - NSRunLoop.currentRunLoop.runUntilDate(None) + await super().redraw(message=message) @property def enabled(self): diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 9b308a4409..12be863bb3 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -30,7 +30,8 @@ async def app_probe(app): module = import_module("tests_backend.app") probe = getattr(module, "AppProbe")(app) - await probe.redraw(f"\nConstructing {app.__class__.__name__} probe") + if app.run_slow: + print("\nConstructing app probe") yield probe From 12c7a47d52c683db27514e521cc8af5934e4074e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 09:01:40 +0800 Subject: [PATCH 08/17] Testbed probe for Android paths. --- android/tests_backend/app.py | 31 +++++++++++++++++++++++++++ android/tests_backend/probe.py | 19 ++++++++++++++++ android/tests_backend/widgets/base.py | 18 +++++----------- 3 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 android/tests_backend/app.py create mode 100644 android/tests_backend/probe.py diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py new file mode 100644 index 0000000000..97afc81455 --- /dev/null +++ b/android/tests_backend/app.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from toga_android.libs.activity import MainActivity + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + def __init__(self, app): + super().__init__() + self.app = app + assert isinstance(self.app._impl.native, MainActivity) + + def get_app_context(self): + return self.app._impl.native.getApplicationContext() + + @property + def config_path(self): + return Path(self.get_app_context().getFilesDir().getPath()) / "log" + + @property + def data_path(self): + return Path(self.get_app_context().getFilesDir().getPath()) / "data" + + @property + def cache_path(self): + return Path(self.get_app_context().getCacheDir().getPath()) + + @property + def logs_path(self): + return Path(self.get_app_context().getFilesDir().getPath()) / "log" diff --git a/android/tests_backend/probe.py b/android/tests_backend/probe.py new file mode 100644 index 0000000000..3632eb05c0 --- /dev/null +++ b/android/tests_backend/probe.py @@ -0,0 +1,19 @@ +import asyncio + +from toga.fonts import SYSTEM + + +class BaseProbe: + def assert_font_family(self, expected): + actual = self.font.family + if expected == SYSTEM: + assert actual == "sans-serif" + else: + assert actual == expected + + async def redraw(self, message=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # If we're running slow, wait for a second + if self.app.run_slow: + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(1) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 765dfd8b13..7c074e1509 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -12,9 +12,9 @@ from android.os import Build from android.view import View, ViewTreeObserver from toga.colors import TRANSPARENT -from toga.fonts import SYSTEM from toga.style.pack import JUSTIFY, LEFT +from ..probe import BaseProbe from .properties import toga_color @@ -28,8 +28,10 @@ def onGlobalLayout(self): self.event.clear() -class SimpleProbe: +class SimpleProbe(BaseProbe): def __init__(self, widget): + super().__init__() + self.app = widget.app self.widget = widget self.native = widget._impl.native self.layout_listener = LayoutListener() @@ -70,22 +72,12 @@ def assert_alignment(self, expected): else: assert actual == expected - def assert_font_family(self, expected): - actual = self.font.family - if expected == SYSTEM: - assert actual == "sans-serif" - else: - assert actual == expected - async def redraw(self, message=None): """Request a redraw of the app, waiting until that redraw has completed.""" self.native.requestLayout() await self.layout_listener.event.wait() - # If we're running slow, wait for a second - if self.widget.app.run_slow: - print("Waiting for redraw" if message is None else message) - await asyncio.sleep(1) + await super().redraw(message=message) @property def enabled(self): From 438489c6a230b35a53c6632f96dd5a9fb315280e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 09:15:55 +0800 Subject: [PATCH 09/17] Testbed coverage for GTK paths. --- gtk/tests_backend/app.py | 28 ++++++++++++++++++++++++++++ gtk/tests_backend/probe.py | 22 ++++++++++++++++++++++ gtk/tests_backend/widgets/base.py | 21 ++++++--------------- 3 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 gtk/tests_backend/app.py create mode 100644 gtk/tests_backend/probe.py diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py new file mode 100644 index 0000000000..28cac82fe9 --- /dev/null +++ b/gtk/tests_backend/app.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from toga_gtk.libs import Gtk + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + def __init__(self, app): + super().__init__() + self.app = app + assert isinstance(self.app._impl.native, Gtk.Application) + + @property + def config_path(self): + return Path.home() / ".config" / "testbed" + + @property + def data_path(self): + return Path.home() / ".local" / "share" / "testbed" + + @property + def cache_path(self): + return Path.home() / ".cache" / "testbed" + + @property + def logs_path(self): + return Path.home() / ".cache" / "testbed" / "log" diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py new file mode 100644 index 0000000000..1006f933e7 --- /dev/null +++ b/gtk/tests_backend/probe.py @@ -0,0 +1,22 @@ +import asyncio + +from toga_gtk.libs import Gtk + + +class BaseProbe: + def assert_font_family(self, expected): + assert self.font.family == expected + + def repaint_needed(self): + return Gtk.events_pending() + + async def redraw(self, message=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # Force a repaint + while self.repaint_needed(): + Gtk.main_iteration_do(blocking=False) + + # If we're running slow, wait for a second + if self.app.run_slow: + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(1) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 6a94804da2..e8d3da93d3 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,13 +1,15 @@ -import asyncio from threading import Event from toga_gtk.libs import Gdk, Gtk +from ..probe import BaseProbe from .properties import toga_color, toga_font -class SimpleProbe: +class SimpleProbe(BaseProbe): def __init__(self, widget): + super().__init__() + self.app = widget.app self.widget = widget self.impl = widget._impl self.native = widget._impl.native @@ -35,19 +37,8 @@ def assert_not_contained(self): def assert_alignment(self, expected): assert self.alignment == expected - def assert_font_family(self, expected): - assert self.font.family == expected - - async def redraw(self, message=None): - """Request a redraw of the app, waiting until that redraw has completed.""" - # Force a repaint - while self.impl.container.needs_redraw or Gtk.events_pending(): - Gtk.main_iteration_do(blocking=False) - - # If we're running slow, wait for a second - if self.widget.app.run_slow: - print("Waiting for redraw" if message is None else message) - await asyncio.sleep(1) + def repaint_needed(self): + return self.impl.container.needs_redraw or super().repaint_needed() @property def enabled(self): From 873cb7f13c86b103dbf239a532dff3cdd9369785 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 09:41:34 +0800 Subject: [PATCH 10/17] Testbed coverage for winforms paths. --- changes/1964.removal.1.rst | 1 + changes/1964.removal.2.rst | 1 + changes/1964.removal.rst | 1 - winforms/src/toga_winforms/paths.py | 6 ++-- winforms/tests_backend/app.py | 45 ++++++++++++++++++++++++++ winforms/tests_backend/probe.py | 26 +++++++++++++++ winforms/tests_backend/widgets/base.py | 29 +++-------------- 7 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 changes/1964.removal.1.rst create mode 100644 changes/1964.removal.2.rst delete mode 100644 changes/1964.removal.rst create mode 100644 winforms/tests_backend/app.py create mode 100644 winforms/tests_backend/probe.py diff --git a/changes/1964.removal.1.rst b/changes/1964.removal.1.rst new file mode 100644 index 0000000000..2945eb7b2f --- /dev/null +++ b/changes/1964.removal.1.rst @@ -0,0 +1 @@ +The location returned by ``toga.App.paths.app`` is now the folder that contains the Python source file that defines the app class used by the app. If you are using a ``toga.App`` instance directly, this may alter the path that is returned. diff --git a/changes/1964.removal.2.rst b/changes/1964.removal.2.rst new file mode 100644 index 0000000000..3dbe9045e2 --- /dev/null +++ b/changes/1964.removal.2.rst @@ -0,0 +1 @@ +On Winforms, if an application doesn't define an author, an author of ``Unknown`` is now used in application data paths, rather than ``Toga``. diff --git a/changes/1964.removal.rst b/changes/1964.removal.rst deleted file mode 100644 index 18bcffce45..0000000000 --- a/changes/1964.removal.rst +++ /dev/null @@ -1 +0,0 @@ -The location returned by ``toga.App.paths.app`` is now the folder that contains the file that defines the app class used by the app. If you are using a ``toga.App`` instance directly, this may alter the path that is returned. diff --git a/winforms/src/toga_winforms/paths.py b/winforms/src/toga_winforms/paths.py index 59eca590ba..924cc1b8ce 100644 --- a/winforms/src/toga_winforms/paths.py +++ b/winforms/src/toga_winforms/paths.py @@ -9,8 +9,10 @@ def __init__(self, interface): @property def author(self): - if App.app.author is None: - return "Toga" + # No coverage testing of this because we can't easily configure + # the app to have no author. + if App.app.author is None: # pragma: no cover + return "Unknown" return App.app.author def get_config_path(self): diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py new file mode 100644 index 0000000000..ab440c071d --- /dev/null +++ b/winforms/tests_backend/app.py @@ -0,0 +1,45 @@ +from pathlib import Path + +from toga_winforms.libs import WinForms + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + def __init__(self, app): + super().__init__() + self.app = app + # The Winforms Application class is a singleton instance + assert self.app._impl.native == WinForms.Application + + @property + def config_path(self): + return ( + Path.home() + / "AppData" + / "Local" + / "Tiberius Yak" + / "Toga Testbed" + / "Config" + ) + + @property + def data_path(self): + return Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" + + @property + def cache_path(self): + return ( + Path.home() + / "AppData" + / "Local" + / "Tiberius Yak" + / "Toga Testbed" + / "Cache" + ) + + @property + def logs_path(self): + return ( + Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Logs" + ) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py new file mode 100644 index 0000000000..44ca340b0c --- /dev/null +++ b/winforms/tests_backend/probe.py @@ -0,0 +1,26 @@ +import asyncio + +from System.Drawing import FontFamily, SystemFonts + +from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM + + +class BaseProbe: + def assert_font_family(self, expected): + assert self.font.family == { + CURSIVE: "Comic Sans MS", + FANTASY: "Impact", + MONOSPACE: FontFamily.GenericMonospace.Name, + SANS_SERIF: FontFamily.GenericSansSerif.Name, + SERIF: FontFamily.GenericSerif.Name, + SYSTEM: SystemFonts.DefaultFont.FontFamily.Name, + }.get(expected, expected) + + async def redraw(self, message=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # Winforms style changes always take effect immediately. + + # If we're running slow, wait for a second + if self.app.run_slow: + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(1) diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 7fd3421ed7..bd9421fdf6 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -1,14 +1,12 @@ -import asyncio - from pytest import approx from System import EventArgs, Object -from System.Drawing import FontFamily, SystemColors, SystemFonts +from System.Drawing import SystemColors from System.Windows.Forms import SendKeys from toga.colors import TRANSPARENT -from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM from toga.style.pack import JUSTIFY, LEFT +from ..probe import BaseProbe from .properties import toga_color, toga_font KEY_CODES = { @@ -22,8 +20,10 @@ ) -class SimpleProbe: +class SimpleProbe(BaseProbe): def __init__(self, widget): + super().__init__() + self.app = widget.app self.widget = widget self.native = widget._impl.native assert isinstance(self.native, self.native_class) @@ -49,25 +49,6 @@ def assert_alignment(self, expected): else: assert actual == expected - def assert_font_family(self, expected): - assert self.font.family == { - CURSIVE: "Comic Sans MS", - FANTASY: "Impact", - MONOSPACE: FontFamily.GenericMonospace.Name, - SANS_SERIF: FontFamily.GenericSansSerif.Name, - SERIF: FontFamily.GenericSerif.Name, - SYSTEM: SystemFonts.DefaultFont.FontFamily.Name, - }.get(expected, expected) - - async def redraw(self, message=None): - """Request a redraw of the app, waiting until that redraw has completed.""" - # Winforms style changes always take effect immediately. - - # If we're running slow, wait for a second - if self.widget.app.run_slow: - print("Waiting for redraw" if message is None else message) - await asyncio.sleep(1) - @property def enabled(self): return self.native.Enabled From 0209af902c56d0ad3ec4bd30bed46ba6c9a759d2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 10:00:03 +0800 Subject: [PATCH 11/17] Tweak Android config path. --- android/src/toga_android/paths.py | 2 +- android/tests_backend/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/toga_android/paths.py b/android/src/toga_android/paths.py index 190cafaeb5..81c0af9396 100644 --- a/android/src/toga_android/paths.py +++ b/android/src/toga_android/paths.py @@ -12,7 +12,7 @@ def __context(self): return App.app._impl.native.getApplicationContext() def get_config_path(self): - return Path(self.__context.getFilesDir().getPath()) / "log" + return Path(self.__context.getFilesDir().getPath()) / "config" def get_data_path(self): return Path(self.__context.getFilesDir().getPath()) / "data" diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 97afc81455..0ff7a5cace 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -16,7 +16,7 @@ def get_app_context(self): @property def config_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "log" + return Path(self.get_app_context().getFilesDir().getPath()) / "config" @property def data_path(self): From eeea599c03e7965be8ecb2f54d9d310f47dec38c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Jun 2023 10:00:16 +0800 Subject: [PATCH 12/17] Tweaked documentation for paths. --- docs/reference/api/resources/paths.rst | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/reference/api/resources/paths.rst b/docs/reference/api/resources/paths.rst index e044b3c3e6..f938620fd3 100644 --- a/docs/reference/api/resources/paths.rst +++ b/docs/reference/api/resources/paths.rst @@ -16,20 +16,22 @@ Usage When Python code executes from the command line, the working directory is a known location - the location where the application was started. However, when -building GUI apps, there is no "working directory". As a result, when specifying +building GUI apps, there is no working directory. As a result, when specifying file paths, relative paths cannot be used, as there is no location to which they can be considered relative. -Complicating matters further, operating systems have conventions over where -certain file types should be stored. For example, macOS provides the -``~/Library/Application Support`` folder; Linux encourages use of the -``~/.config`` folder (amongst others), and Windows provides the -``AppData/Local`` folder. +Complicating matters further, operating systems have conventions (and in some +cases, hard restrictions) over where certain file types should be stored. For +example, macOS provides the ``~/Library/Application Support`` folder; Linux +encourages use of the ``~/.config`` folder (amongst others), and Windows +provides the ``AppData/Local`` folder in the user's home directory. Application +sandbox and security policies will prevent sometimes prevent reading or +writing files in any location other than these pre-approved locations. To assist with finding an appropriate location to store application files, every Toga application has a ``paths`` object that provides known file system locations that are appropriate for storing files of given types, such as -configuration files, log files, cache files, or user documents. +configuration files, log files, cache files, or user data. Each location provided by the ``paths`` object is a :class:`Pathlib.Path` that can be used to construct a full file path. If required, additional sub-folders @@ -37,7 +39,8 @@ can be created under these locations. The paths returned are *not* guaranteed to be empty or unique. For example, you should not assume that the user data location *only* contains user data files. -Depending on platform conventions, there may be other files or folders. +Depending on platform conventions, the user data folder may be shared with +files or folders with other purposes. You should not assume that any of these paths already exist. The location is guaranteed to follow operating system conventions, but the application is From 466507420389f61b9f87e26ae25af554547ff717 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 07:14:43 +0800 Subject: [PATCH 13/17] Documentation tweaks. --- core/src/toga/app.py | 6 +++--- docs/reference/api/index.rst | 10 +++++---- docs/reference/api/resources/index.rst | 2 +- docs/reference/api/resources/paths.rst | 23 +++++++++++---------- docs/reference/data/widgets_by_platform.csv | 4 ++-- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 13360bb9ab..44f355a314 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -358,9 +358,9 @@ def paths(self) -> Paths: disk; even when arbitrary file system access is allowed, there are "preferred" locations for some types of content. - The ``paths`` object has a set of sub-properties that return - ``pathlib.Path`` instances of platform-appropriate paths on the - file system. + The :class:`~toga.paths.Paths` object has a set of sub-properties that + return :class:`pathlib.Path` instances of platform-appropriate paths on + the file system. """ return self._paths diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 5a7ce0539b..dadd793253 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -66,16 +66,18 @@ Layout widgets Resources --------- -========================================================= ================================= +========================================================= ======================================================================== Component Description -========================================================= ================================= +========================================================= ======================================================================== + :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations + for an application. :doc:`Font ` Fonts :doc:`Command ` Command :doc:`Group ` Command group :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image - :doc:`Validators ` Input validators -========================================================= ================================= + :doc:`Validators ` A mechanism for validating that input meets a given set of criteria. +========================================================= ======================================================================== .. toctree:: :hidden: diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 33ca3d17a0..642148bfa0 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -3,10 +3,10 @@ Resources .. toctree:: + paths fonts command group icons images - paths validators diff --git a/docs/reference/api/resources/paths.rst b/docs/reference/api/resources/paths.rst index f938620fd3..ac6920cc80 100644 --- a/docs/reference/api/resources/paths.rst +++ b/docs/reference/api/resources/paths.rst @@ -1,5 +1,5 @@ App Paths -========== +========= A mechanism for obtaining platform-appropriate file system locations for an application. @@ -16,9 +16,9 @@ Usage When Python code executes from the command line, the working directory is a known location - the location where the application was started. However, when -building GUI apps, there is no working directory. As a result, when specifying -file paths, relative paths cannot be used, as there is no location to which they -can be considered relative. +executing GUI apps, the working directory varies between platforms. As a result, +when specifying file paths, relative paths cannot be used, as there is no +location to which they can be considered relative. Complicating matters further, operating systems have conventions (and in some cases, hard restrictions) over where certain file types should be stored. For @@ -29,13 +29,14 @@ sandbox and security policies will prevent sometimes prevent reading or writing files in any location other than these pre-approved locations. To assist with finding an appropriate location to store application files, every -Toga application has a ``paths`` object that provides known file system -locations that are appropriate for storing files of given types, such as -configuration files, log files, cache files, or user data. - -Each location provided by the ``paths`` object is a :class:`Pathlib.Path` that -can be used to construct a full file path. If required, additional sub-folders -can be created under these locations. +Toga application instance has a :attr:`~toga.app.App.paths` attribute that +returns an instance of :class:`~toga.paths.Paths`. This object provides known +file system locations that are appropriate for storing files of given types, +such as configuration files, log files, cache files, or user data. + +Each location provided by the :class:`~toga.paths.Paths` object is a +:class:`pathlib.Path` that can be used to construct a full file path. If +required, additional sub-folders can be created under these locations. The paths returned are *not* guaranteed to be empty or unique. For example, you should not assume that the user data location *only* contains user data files. diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index be4c46e0a8..96c93094c6 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -27,10 +27,10 @@ Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|y|,|y ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,Scrollable Container,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, +App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, Font,Resource,:class:`~toga.fonts.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, Command,Resource,:class:`~toga.command.Command`,Command,|b|,|b|,|b|,,|b|, Group,Resource,:class:`~toga.command.Group`,Command group,|b|,|b|,|b|,|b|,|b|, Icon,Resource,:class:`~toga.icons.Icon`,"An icon for buttons, menus, etc",|b|,|b|,|b|,|b|,|b|, Image,Resource,:class:`~toga.images.Image`,An image,|b|,|b|,|b|,|b|,|b|, -App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, -Validators,Resource,:ref:`Validators `,Input validators,|y|,|y|,|y|,|y|,|y|, +Validators,Resource,:ref:`Validators `,A mechanism for validating that input meets a given set of criteria.,|y|,|y|,|y|,|y|,|y|, From a4f73f45edb6b1a694acfa60e3b67e449613ff3e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 07:48:38 +0800 Subject: [PATCH 14/17] Tweaks to config paths. --- android/src/toga_android/paths.py | 4 ++-- android/tests_backend/app.py | 4 ++-- cocoa/src/toga_cocoa/paths.py | 2 +- cocoa/tests_backend/app.py | 4 +--- gtk/src/toga_gtk/paths.py | 2 +- gtk/tests_backend/app.py | 2 +- winforms/src/toga_winforms/paths.py | 9 +-------- winforms/tests_backend/app.py | 9 +-------- 8 files changed, 10 insertions(+), 26 deletions(-) diff --git a/android/src/toga_android/paths.py b/android/src/toga_android/paths.py index 81c0af9396..3b84e9df96 100644 --- a/android/src/toga_android/paths.py +++ b/android/src/toga_android/paths.py @@ -12,10 +12,10 @@ def __context(self): return App.app._impl.native.getApplicationContext() def get_config_path(self): - return Path(self.__context.getFilesDir().getPath()) / "config" + return Path(self.__context.getFilesDir().getPath()) def get_data_path(self): - return Path(self.__context.getFilesDir().getPath()) / "data" + return Path(self.__context.getFilesDir().getPath()) def get_cache_path(self): return Path(self.__context.getCacheDir().getPath()) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 0ff7a5cace..7ef3ab3e8d 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -16,11 +16,11 @@ def get_app_context(self): @property def config_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "config" + return Path(self.get_app_context().getFilesDir().getPath()) @property def data_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "data" + return Path(self.get_app_context().getFilesDir().getPath()) @property def cache_path(self): diff --git a/cocoa/src/toga_cocoa/paths.py b/cocoa/src/toga_cocoa/paths.py index bcb00053c3..5871de0398 100644 --- a/cocoa/src/toga_cocoa/paths.py +++ b/cocoa/src/toga_cocoa/paths.py @@ -8,7 +8,7 @@ def __init__(self, interface): self.interface = interface def get_config_path(self): - return Path.home() / "Library" / "Application Support" / App.app.app_id + return Path.home() / "Library" / "Preferences" / App.app.app_id def get_data_path(self): return Path.home() / "Library" / "Application Support" / App.app.app_id diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index a9ee2337a5..e06b9e1c2b 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -13,9 +13,7 @@ def __init__(self, app): @property def config_path(self): - return ( - Path.home() / "Library" / "Application Support" / "org.beeware.toga.testbed" - ) + return Path.home() / "Library" / "Preferences" / "org.beeware.toga.testbed" @property def data_path(self): diff --git a/gtk/src/toga_gtk/paths.py b/gtk/src/toga_gtk/paths.py index a946f9170a..d13a6e665b 100644 --- a/gtk/src/toga_gtk/paths.py +++ b/gtk/src/toga_gtk/paths.py @@ -17,4 +17,4 @@ def get_cache_path(self): return Path.home() / ".cache" / App.app.app_name def get_logs_path(self): - return Path.home() / ".cache" / App.app.app_name / "log" + return Path.home() / ".local" / "state" / App.app.app_name / "log" diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 28cac82fe9..72d677d0c7 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -25,4 +25,4 @@ def cache_path(self): @property def logs_path(self): - return Path.home() / ".cache" / "testbed" / "log" + return Path.home() / ".local" / "state" / "testbed" / "log" diff --git a/winforms/src/toga_winforms/paths.py b/winforms/src/toga_winforms/paths.py index 924cc1b8ce..6a5e400413 100644 --- a/winforms/src/toga_winforms/paths.py +++ b/winforms/src/toga_winforms/paths.py @@ -16,14 +16,7 @@ def author(self): return App.app.author def get_config_path(self): - return ( - Path.home() - / "AppData" - / "Local" - / self.author - / App.app.formal_name - / "Config" - ) + return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name def get_data_path(self): return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index ab440c071d..7c22c453aa 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -14,14 +14,7 @@ def __init__(self, app): @property def config_path(self): - return ( - Path.home() - / "AppData" - / "Local" - / "Tiberius Yak" - / "Toga Testbed" - / "Config" - ) + return Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" @property def data_path(self): From 5f12ce81868bc51b7854df05efa0f46a02e80362 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 08:02:01 +0800 Subject: [PATCH 15/17] Update changenotes. --- changes/{1964.feature.rst => 1964.feature.1.rst} | 0 changes/1964.feature.2.rst | 1 + changes/1964.removal.3.rst | 1 + 3 files changed, 2 insertions(+) rename changes/{1964.feature.rst => 1964.feature.1.rst} (100%) create mode 100644 changes/1964.feature.2.rst create mode 100644 changes/1964.removal.3.rst diff --git a/changes/1964.feature.rst b/changes/1964.feature.1.rst similarity index 100% rename from changes/1964.feature.rst rename to changes/1964.feature.1.rst diff --git a/changes/1964.feature.2.rst b/changes/1964.feature.2.rst new file mode 100644 index 0000000000..1f4d50f228 --- /dev/null +++ b/changes/1964.feature.2.rst @@ -0,0 +1 @@ +The app paths now include a ``config`` path for storing config files. diff --git a/changes/1964.removal.3.rst b/changes/1964.removal.3.rst new file mode 100644 index 0000000000..debf6198aa --- /dev/null +++ b/changes/1964.removal.3.rst @@ -0,0 +1 @@ +GTK now returns ``~/.local/state/appname/log`` as the log file location, rather than ``~/.cache/appname/log``. From 03afdd28fbfda01da41a555e78c7ef6b5d8cc2ee Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 15:30:37 +0800 Subject: [PATCH 16/17] Make app paths documentation naming internally consistent. --- docs/reference/api/index.rst | 2 +- docs/reference/api/resources/{paths.rst => app_paths.rst} | 0 docs/reference/api/resources/index.rst | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/reference/api/resources/{paths.rst => app_paths.rst} (100%) diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index dadd793253..a64b47eabc 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -69,7 +69,7 @@ Resources ========================================================= ======================================================================== Component Description ========================================================= ======================================================================== - :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations + :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations for an application. :doc:`Font ` Fonts :doc:`Command ` Command diff --git a/docs/reference/api/resources/paths.rst b/docs/reference/api/resources/app_paths.rst similarity index 100% rename from docs/reference/api/resources/paths.rst rename to docs/reference/api/resources/app_paths.rst diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 642148bfa0..9c3a2bae44 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -3,7 +3,7 @@ Resources .. toctree:: - paths + app_paths fonts command group From aed519aa283680e42e3782bca4e2aece433e8cd5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 15:42:40 +0800 Subject: [PATCH 17/17] Ensure that all app paths are unique locations. --- android/src/toga_android/paths.py | 4 ++-- android/tests_backend/app.py | 4 ++-- changes/1964.removal.3.rst | 2 +- changes/1964.removal.4.rst | 1 + changes/1964.removal.5.rst | 1 + docs/reference/api/resources/app_paths.rst | 5 ----- winforms/src/toga_winforms/paths.py | 18 ++++++++++++++++-- winforms/tests_backend/app.py | 13 +++++++++++-- 8 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 changes/1964.removal.4.rst create mode 100644 changes/1964.removal.5.rst diff --git a/android/src/toga_android/paths.py b/android/src/toga_android/paths.py index 3b84e9df96..81c0af9396 100644 --- a/android/src/toga_android/paths.py +++ b/android/src/toga_android/paths.py @@ -12,10 +12,10 @@ def __context(self): return App.app._impl.native.getApplicationContext() def get_config_path(self): - return Path(self.__context.getFilesDir().getPath()) + return Path(self.__context.getFilesDir().getPath()) / "config" def get_data_path(self): - return Path(self.__context.getFilesDir().getPath()) + return Path(self.__context.getFilesDir().getPath()) / "data" def get_cache_path(self): return Path(self.__context.getCacheDir().getPath()) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 7ef3ab3e8d..0ff7a5cace 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -16,11 +16,11 @@ def get_app_context(self): @property def config_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) + return Path(self.get_app_context().getFilesDir().getPath()) / "config" @property def data_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) + return Path(self.get_app_context().getFilesDir().getPath()) / "data" @property def cache_path(self): diff --git a/changes/1964.removal.3.rst b/changes/1964.removal.3.rst index debf6198aa..22bcae6c19 100644 --- a/changes/1964.removal.3.rst +++ b/changes/1964.removal.3.rst @@ -1 +1 @@ -GTK now returns ``~/.local/state/appname/log`` as the log file location, rather than ``~/.cache/appname/log``. +Winforms now returns ``AppData/Local///Data`` as the user data file location, rather than ``AppData/Local//``. diff --git a/changes/1964.removal.4.rst b/changes/1964.removal.4.rst new file mode 100644 index 0000000000..b22fe6ab97 --- /dev/null +++ b/changes/1964.removal.4.rst @@ -0,0 +1 @@ +On Android, the user data folder is now a ``data`` subdirectory of the location returned by ``context.getFilesDir()``, rather than the bare ``context.getFilesDir()`` location. diff --git a/changes/1964.removal.5.rst b/changes/1964.removal.5.rst new file mode 100644 index 0000000000..debf6198aa --- /dev/null +++ b/changes/1964.removal.5.rst @@ -0,0 +1 @@ +GTK now returns ``~/.local/state/appname/log`` as the log file location, rather than ``~/.cache/appname/log``. diff --git a/docs/reference/api/resources/app_paths.rst b/docs/reference/api/resources/app_paths.rst index ac6920cc80..1fb61abe1e 100644 --- a/docs/reference/api/resources/app_paths.rst +++ b/docs/reference/api/resources/app_paths.rst @@ -38,11 +38,6 @@ Each location provided by the :class:`~toga.paths.Paths` object is a :class:`pathlib.Path` that can be used to construct a full file path. If required, additional sub-folders can be created under these locations. -The paths returned are *not* guaranteed to be empty or unique. For example, you -should not assume that the user data location *only* contains user data files. -Depending on platform conventions, the user data folder may be shared with -files or folders with other purposes. - You should not assume that any of these paths already exist. The location is guaranteed to follow operating system conventions, but the application is responsible for ensuring the folder exists prior to writing files in these diff --git a/winforms/src/toga_winforms/paths.py b/winforms/src/toga_winforms/paths.py index 6a5e400413..1259513726 100644 --- a/winforms/src/toga_winforms/paths.py +++ b/winforms/src/toga_winforms/paths.py @@ -16,10 +16,24 @@ def author(self): return App.app.author def get_config_path(self): - return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name + return ( + Path.home() + / "AppData" + / "Local" + / self.author + / App.app.formal_name + / "Config" + ) def get_data_path(self): - return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name + return ( + Path.home() + / "AppData" + / "Local" + / self.author + / App.app.formal_name + / "Data" + ) def get_cache_path(self): return ( diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 7c22c453aa..686dc4ba89 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -14,11 +14,20 @@ def __init__(self, app): @property def config_path(self): - return Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" + return ( + Path.home() + / "AppData" + / "Local" + / "Tiberius Yak" + / "Toga Testbed" + / "Config" + ) @property def data_path(self): - return Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" + return ( + Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Data" + ) @property def cache_path(self):