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/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/android/src/toga_android/paths.py b/android/src/toga_android/paths.py index 5b22781ef0..81c0af9396 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()) / "config" - @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/android/tests_backend/app.py b/android/tests_backend/app.py new file mode 100644 index 0000000000..0ff7a5cace --- /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()) / "config" + + @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): diff --git a/changes/1964.feature.1.rst b/changes/1964.feature.1.rst new file mode 100644 index 0000000000..ecdf5b3422 --- /dev/null +++ b/changes/1964.feature.1.rst @@ -0,0 +1 @@ +The Paths property of apps now has 100% test coverage, and complete API documentation. 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.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.3.rst b/changes/1964.removal.3.rst new file mode 100644 index 0000000000..22bcae6c19 --- /dev/null +++ b/changes/1964.removal.3.rst @@ -0,0 +1 @@ +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/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..5871de0398 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" / "Preferences" / 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/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py new file mode 100644 index 0000000000..e06b9e1c2b --- /dev/null +++ b/cocoa/tests_backend/app.py @@ -0,0 +1,30 @@ +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" / "Preferences" / "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/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..44f355a314 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,36 +351,16 @@ 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 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. - - :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. + 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/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..f98d3ea1f9 100644 --- a/core/tests/test_paths.py +++ b/core/tests/test_paths.py @@ -1,160 +1,132 @@ +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" + # 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. + # 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/index.rst b/docs/reference/api/index.rst index 5a7ce0539b..a64b47eabc 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/app_paths.rst b/docs/reference/api/resources/app_paths.rst new file mode 100644 index 0000000000..1fb61abe1e --- /dev/null +++ b/docs/reference/api/resources/app_paths.rst @@ -0,0 +1,51 @@ +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 +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 +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 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. + +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/api/resources/index.rst b/docs/reference/api/resources/index.rst index 08db6dfe71..9c3a2bae44 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -3,6 +3,7 @@ Resources .. toctree:: + app_paths fonts command group diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index af4795e225..96c93094c6 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -27,9 +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|, -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|, 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..d13a6e665b 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): - 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() + def get_logs_path(self): + return Path.home() / ".local" / "state" / App.app.app_name / "log" 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/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/gtk/tests_backend/app.py b/gtk/tests_backend/app.py new file mode 100644 index 0000000000..72d677d0c7 --- /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() / ".local" / "state" / "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): 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/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/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 0f68fa7052..168728a597 100644 --- a/iOS/src/toga_iOS/paths.py +++ b/iOS/src/toga_iOS/paths.py @@ -1,43 +1,27 @@ -import sys from pathlib import Path -import toga -from toga import App +from toga_iOS.libs import NSFileManager, NSSearchPathDirectory, NSSearchPathDomainMask 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 - - @property - def logs(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() + 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 self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Config" + + def get_data_path(self): + return self.get_path(NSSearchPathDirectory.Documents) + + def get_cache_path(self): + return self.get_path(NSSearchPathDirectory.Cache) + + def get_logs_path(self): + 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 00179b1e42..12be863bb3 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,16 @@ def app(): return toga.App.app +@fixture +async def app_probe(app): + module = import_module("tests_backend.app") + probe = getattr(module, "AppProbe")(app) + + if app.run_slow: + print("\nConstructing app 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) 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/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() diff --git a/winforms/src/toga_winforms/paths.py b/winforms/src/toga_winforms/paths.py index b3569ca107..1259513726 100644 --- a/winforms/src/toga_winforms/paths.py +++ b/winforms/src/toga_winforms/paths.py @@ -1,39 +1,41 @@ -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): - 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 - @property - def data(self): - return Path.home() / "AppData" / "Local" / self.author / App.app.formal_name + def get_config_path(self): + return ( + Path.home() + / "AppData" + / "Local" + / self.author + / App.app.formal_name + / "Config" + ) - @property - def cache(self): + def get_data_path(self): + return ( + Path.home() + / "AppData" + / "Local" + / self.author + / App.app.formal_name + / "Data" + ) + + def get_cache_path(self): return ( Path.home() / "AppData" @@ -43,8 +45,7 @@ def cache(self): / "Cache" ) - @property - def logs(self): + def get_logs_path(self): return ( Path.home() / "AppData" @@ -53,11 +54,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, - ) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py new file mode 100644 index 0000000000..686dc4ba89 --- /dev/null +++ b/winforms/tests_backend/app.py @@ -0,0 +1,47 @@ +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" / "Data" + ) + + @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