diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2263aaf..fe3d462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: - id: mypy args: ["--config-file", "pyproject.toml"] stages: [manual] - additional_dependencies: [pytest] + additional_dependencies: [pytest, platformdirs] exclude: | exclude: | (?x)^( diff --git a/jupyter_core/command.py b/jupyter_core/command.py index 33804c9..3a954b7 100644 --- a/jupyter_core/command.py +++ b/jupyter_core/command.py @@ -231,6 +231,15 @@ def main(): if args.debug: env = os.environ + if paths.use_platform_dirs(): + print( + "JUPYTER_PLATFORM_DIRS is set to a true value, so we use platformdirs to find platform-specific directories" + ) + else: + print( + "JUPYTER_PLATFORM_DIRS is set to a false value, or is not set, so we use hardcoded legacy paths for platform-specific directories" + ) + if paths.prefer_environment_over_user(): print( "JUPYTER_PREFER_ENV_PATH is set to a true value, or JUPYTER_PREFER_ENV_PATH is not set and we detected a virtual environment, making the environment-level path preferred over the user-level path for data and config" diff --git a/jupyter_core/paths.py b/jupyter_core/paths.py index 7dafd91..6dcffb2 100644 --- a/jupyter_core/paths.py +++ b/jupyter_core/paths.py @@ -19,8 +19,15 @@ from pathlib import Path from typing import Optional +import platformdirs + +from jupyter_core.utils import deprecation + pjoin = os.path.join +# Capitalize Jupyter in paths only on Windows and MacOS +APPNAME = "Jupyter" if sys.platform in ("win32", "darwin") else "jupyter" + # UF_HIDDEN is a stat flag not defined in the stat module. # It is used by BSD to indicate hidden files. UF_HIDDEN = getattr(stat, "UF_HIDDEN", 32768) @@ -40,6 +47,15 @@ def envset(name, default=False): return os.environ[name].lower() not in ["no", "n", "false", "off", "0", "0.0"] +def use_platform_dirs(): + """Determine if platformdirs should be used for system-specific paths. + + We plan for this to default to False in jupyter_core version 5 and to True + in jupyter_core version 6. + """ + return envset("JUPYTER_PLATFORM_DIRS", False) + + def get_home_dir(): """Get the real path of the home directory""" homedir = os.path.expanduser("~") @@ -85,7 +101,8 @@ def _mkdtemp_once(name): def jupyter_config_dir(): """Get the Jupyter config directory for this platform and user. - Returns JUPYTER_CONFIG_DIR if defined, else ~/.jupyter + Returns JUPYTER_CONFIG_DIR if defined, otherwise the appropriate + directory for the platform. """ env = os.environ @@ -95,6 +112,9 @@ def jupyter_config_dir(): if env.get("JUPYTER_CONFIG_DIR"): return env["JUPYTER_CONFIG_DIR"] + if use_platform_dirs(): + return platformdirs.user_config_dir(APPNAME, appauthor=False) + home_dir = get_home_dir() return pjoin(home_dir, ".jupyter") @@ -111,6 +131,9 @@ def jupyter_data_dir(): if env.get("JUPYTER_DATA_DIR"): return env["JUPYTER_DATA_DIR"] + if use_platform_dirs(): + return platformdirs.user_data_dir(APPNAME, appauthor=False) + home = get_home_dir() if sys.platform == "darwin": @@ -145,17 +168,29 @@ def jupyter_runtime_dir(): return pjoin(jupyter_data_dir(), "runtime") -if os.name == "nt": - programdata = os.environ.get("PROGRAMDATA", None) - if programdata: - SYSTEM_JUPYTER_PATH = [pjoin(programdata, "jupyter")] - else: # PROGRAMDATA is not defined by default on XP. - SYSTEM_JUPYTER_PATH = [os.path.join(sys.prefix, "share", "jupyter")] +if use_platform_dirs(): + SYSTEM_JUPYTER_PATH = platformdirs.site_data_dir( + APPNAME, appauthor=False, multipath=True + ).split(os.pathsep) else: - SYSTEM_JUPYTER_PATH = [ - "/usr/local/share/jupyter", - "/usr/share/jupyter", - ] + deprecation( + "Jupyter is migrating its paths to use standard platformdirs\n" # noqa + + "given by the platformdirs library. To remove this warning and\n" + + "see the appropriate new directories, set the environment variable\n" + + "`JUPYTER_PLATFORM_DIRS=1` and then run `jupyter --paths`.\n" + + "The use of platformdirs will be the default in `jupyter_core` v6" + ) + if os.name == "nt": + programdata = os.environ.get("PROGRAMDATA", None) + if programdata: + SYSTEM_JUPYTER_PATH = [pjoin(programdata, "jupyter")] + else: # PROGRAMDATA is not defined by default on XP. + SYSTEM_JUPYTER_PATH = [os.path.join(sys.prefix, "share", "jupyter")] + else: + SYSTEM_JUPYTER_PATH = [ + "/usr/local/share/jupyter", + "/usr/share/jupyter", + ] ENV_JUPYTER_PATH = [os.path.join(sys.prefix, "share", "jupyter")] @@ -222,18 +257,22 @@ def jupyter_path(*subdirs): return paths -if os.name == "nt": - programdata = os.environ.get("PROGRAMDATA", None) - if programdata: - SYSTEM_CONFIG_PATH = [os.path.join(programdata, "jupyter")] - else: # PROGRAMDATA is not defined by default on XP. - SYSTEM_CONFIG_PATH = [] +if use_platform_dirs(): + SYSTEM_CONFIG_PATH = platformdirs.site_config_dir( + APPNAME, appauthor=False, multipath=True + ).split(os.pathsep) else: - SYSTEM_CONFIG_PATH = [ - "/usr/local/etc/jupyter", - "/etc/jupyter", - ] - + if os.name == "nt": + programdata = os.environ.get("PROGRAMDATA", None) + if programdata: + SYSTEM_CONFIG_PATH = [os.path.join(programdata, "jupyter")] + else: # PROGRAMDATA is not defined by default on XP. + SYSTEM_CONFIG_PATH = [] + else: + SYSTEM_CONFIG_PATH = [ + "/usr/local/etc/jupyter", + "/etc/jupyter", + ] ENV_CONFIG_PATH = [os.path.join(sys.prefix, "etc", "jupyter")] diff --git a/jupyter_core/tests/test_paths.py b/jupyter_core/tests/test_paths.py index 0d16358..677ee26 100644 --- a/jupyter_core/tests/test_paths.py +++ b/jupyter_core/tests/test_paths.py @@ -29,10 +29,11 @@ secure_write, ) -from .mocking import darwin, linux - pjoin = os.path.join +macos = pytest.mark.skipif(sys.platform != "darwin", reason="only run on macos") +windows = pytest.mark.skipif(sys.platform != "win32", reason="only run on windows") +linux = pytest.mark.skipif(sys.platform != "linux", reason="only run on linux") xdg_env = { "XDG_CONFIG_HOME": "/tmp/xdg/config", @@ -42,16 +43,13 @@ xdg = patch.dict("os.environ", xdg_env) no_xdg = patch.dict( "os.environ", - { - "XDG_CONFIG_HOME": "", - "XDG_DATA_HOME": "", - "XDG_RUNTIME_DIR": "", - }, + {}, ) no_config_env = patch.dict( "os.environ", { + "JUPYTER_PLATFORM_DIRS": "", "JUPYTER_CONFIG_DIR": "", "JUPYTER_DATA_DIR": "", "JUPYTER_RUNTIME_DIR": "", @@ -59,6 +57,8 @@ }, ) +use_platformdirs = patch.dict("os.environ", {"JUPYTER_PLATFORM_DIRS": "1"}) + jupyter_config_env = "/jupyter-cfg" config_env = patch.dict("os.environ", {"JUPYTER_CONFIG_DIR": jupyter_config_env}) prefer_env = patch.dict("os.environ", {"JUPYTER_PREFER_ENV_PATH": "True"}) @@ -99,159 +99,211 @@ def test_envset(): assert paths.envset("THIS_VARIABLE_SHOULD_NOT_BE_SET", None) is None -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_config_dir_darwin(): - with darwin, no_config_env: - config = jupyter_config_dir() +def test_config_dir(): + config = jupyter_config_dir() assert config == home_jupyter - with darwin, config_env: - config = jupyter_config_dir() - assert config == jupyter_config_env + +@macos +@use_platformdirs +def test_config_dir_darwin(): + config = jupyter_config_dir() + assert config == realpath("~/Library/Preferences/Jupyter") -@pytest.mark.skipif(sys.platform != "win32", reason="only run on windows") +@windows +@use_platformdirs def test_config_dir_windows(): - with no_config_env: - config = jupyter_config_dir() - assert config == home_jupyter + config = jupyter_config_dir() + assert config == realpath(pjoin(os.environ.get("LOCALAPPDATA", ""), "Jupyter")) + + +@linux +@use_platformdirs +def test_config_dir_linux(): + config = jupyter_config_dir() + assert config == realpath("~/.config/jupyter") + +def test_config_env_legacy(): with config_env: config = jupyter_config_dir() - assert config == jupyter_config_env + assert config == jupyter_config_env -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_config_dir_linux(): - with linux, no_config_env: +@use_platformdirs +def test_config_env(): + with config_env: config = jupyter_config_dir() - assert config == home_jupyter + assert config == jupyter_config_env - with linux, config_env: - config = jupyter_config_dir() - assert config == jupyter_config_env + +def test_data_dir_env_legacy(): + data_env = "runtime-dir" + with patch.dict("os.environ", {"JUPYTER_DATA_DIR": data_env}): + data = jupyter_data_dir() + assert data == data_env +@use_platformdirs def test_data_dir_env(): data_env = "runtime-dir" with patch.dict("os.environ", {"JUPYTER_DATA_DIR": data_env}): data = jupyter_data_dir() - assert data == data_env + assert data == data_env -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_data_dir_darwin(): - with darwin: - data = jupyter_data_dir() +@macos +def test_data_dir_darwin_legacy(): + data = jupyter_data_dir() assert data == realpath("~/Library/Jupyter") - with darwin, xdg: - # darwin should ignore xdg - data = jupyter_data_dir() - assert data == realpath("~/Library/Jupyter") +@macos +@use_platformdirs +def test_data_dir_darwin(): + data = jupyter_data_dir() + assert data == realpath("~/Library/Application Support/Jupyter") -@pytest.mark.skipif(sys.platform != "win32", reason="only run on windows") -def test_data_dir_windows(): + +@windows +def test_data_dir_windows_legacy(): data = jupyter_data_dir() assert data == realpath(pjoin(os.environ.get("APPDATA", ""), "jupyter")) + +@windows +@use_platformdirs +def test_data_dir_windows(): + data = jupyter_data_dir() + assert data == realpath(pjoin(os.environ.get("LOCALAPPDATA", ""), "Jupyter")) + + +@linux +def test_data_dir_linux_legacy(): + with no_xdg: + data = jupyter_data_dir() + assert data == realpath("~/.local/share/jupyter") + with xdg: - # windows should ignore xdg data = jupyter_data_dir() - assert data == realpath(pjoin(os.environ.get("APPDATA", ""), "jupyter")) + assert data == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter") -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +@linux +@use_platformdirs def test_data_dir_linux(): - with linux, no_xdg: + with no_xdg: data = jupyter_data_dir() - assert data == realpath("~/.local/share/jupyter") + assert data == realpath("~/.local/share/jupyter") - with linux, xdg: + with xdg: data = jupyter_data_dir() - assert data == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter") + assert data == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter") -def test_runtime_dir_env(): +def test_runtime_dir_env_legacy(): rtd_env = "runtime-dir" with patch.dict("os.environ", {"JUPYTER_RUNTIME_DIR": rtd_env}): runtime = jupyter_runtime_dir() - assert runtime == rtd_env + assert runtime == rtd_env -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_runtime_dir_darwin(): - with darwin: +@use_platformdirs +def test_runtime_dir_env(): + rtd_env = "runtime-dir" + with patch.dict("os.environ", {"JUPYTER_RUNTIME_DIR": rtd_env}): runtime = jupyter_runtime_dir() - assert runtime == realpath("~/Library/Jupyter/runtime") + assert runtime == rtd_env - with darwin, xdg: - # darwin should ignore xdg - runtime = jupyter_runtime_dir() + +@macos +def test_runtime_dir_darwin_legacy(): + runtime = jupyter_runtime_dir() assert runtime == realpath("~/Library/Jupyter/runtime") -@pytest.mark.skipif(sys.platform != "win32", reason="only run on windows") -def test_runtime_dir_windows(): +@macos +@use_platformdirs +def test_runtime_dir_darwin(): + runtime = jupyter_runtime_dir() + assert runtime == realpath("~/Library/Application Support/Jupyter/runtime") + + +@windows +def test_runtime_dir_windows_legacy(): runtime = jupyter_runtime_dir() assert runtime == realpath(pjoin(os.environ.get("APPDATA", ""), "jupyter", "runtime")) + +@windows +@use_platformdirs +def test_runtime_dir_windows(): + runtime = jupyter_runtime_dir() + assert runtime == realpath(pjoin(os.environ.get("LOCALAPPDATA", ""), "Jupyter", "runtime")) + + +@linux +def test_runtime_dir_linux_legacy(): + with no_xdg: + runtime = jupyter_runtime_dir() + assert runtime == realpath("~/.local/share/jupyter/runtime") + with xdg: - # windows should ignore xdg runtime = jupyter_runtime_dir() - assert runtime == realpath(pjoin(os.environ.get("APPDATA", ""), "jupyter", "runtime")) + assert runtime == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter", "runtime") -@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +@linux +@use_platformdirs def test_runtime_dir_linux(): - with linux, no_xdg: + with no_xdg: runtime = jupyter_runtime_dir() - assert runtime == realpath("~/.local/share/jupyter/runtime") + assert runtime == realpath("~/.local/share/jupyter/runtime") - with linux, xdg: + with xdg: runtime = jupyter_runtime_dir() - assert runtime == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter", "runtime") + assert runtime == pjoin(xdg_env["XDG_DATA_HOME"], "jupyter", "runtime") def test_jupyter_path(): system_path = ["system", "path"] with no_config_env, patch.object(paths, "SYSTEM_JUPYTER_PATH", system_path): path = jupyter_path() - assert path[0] == jupyter_data_dir() - assert path[-2:] == system_path + assert path[0] == jupyter_data_dir() + assert path[-2:] == system_path def test_jupyter_path_user_site(): with no_config_env, patch.object(site, "ENABLE_USER_SITE", True): path = jupyter_path() - # deduplicated expected values - values = list( - dict.fromkeys( - [ - jupyter_data_dir(), - os.path.join(site.getuserbase(), "share", "jupyter"), - paths.ENV_JUPYTER_PATH[0], - ] + # deduplicated expected values + values = list( + dict.fromkeys( + [ + jupyter_data_dir(), + os.path.join(site.getuserbase(), "share", "jupyter"), + paths.ENV_JUPYTER_PATH[0], + ] + ) ) - ) - for p, v in zip(path, values): - assert p == v + for p, v in zip(path, values): + assert p == v def test_jupyter_path_no_user_site(): with no_config_env, patch.object(site, "ENABLE_USER_SITE", False): path = jupyter_path() - assert path[0] == jupyter_data_dir() - assert path[1] == paths.ENV_JUPYTER_PATH[0] + assert path[0] == jupyter_data_dir() + assert path[1] == paths.ENV_JUPYTER_PATH[0] def test_jupyter_path_prefer_env(): with prefer_env: path = jupyter_path() - assert path[0] == paths.ENV_JUPYTER_PATH[0] - assert path[1] == jupyter_data_dir() + assert path[0] == paths.ENV_JUPYTER_PATH[0] + assert path[1] == jupyter_data_dir() def test_jupyter_path_env(): diff --git a/jupyter_core/utils/__init__.py b/jupyter_core/utils/__init__.py index ff33137..fe844bd 100644 --- a/jupyter_core/utils/__init__.py +++ b/jupyter_core/utils/__init__.py @@ -1,5 +1,12 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + import errno +import inspect import os +import sys +import warnings +from pathlib import Path def ensure_dir_exists(path, mode=0o777): @@ -15,3 +22,62 @@ def ensure_dir_exists(path, mode=0o777): raise if not os.path.isdir(path): raise OSError("%r exists but is not a directory" % path) + + +def _get_frame(level): + """Get the frame at the given stack level.""" + # sys._getframe is much faster than inspect.stack, but isn't guaranteed to + # exist in all python implementations, so we fall back to inspect.stack() + + # We need to add one to level to account for this get_frame call. + if hasattr(sys, "_getframe"): + frame = sys._getframe(level + 1) + else: + frame = inspect.stack(context=0)[level + 1].frame + return frame + + +# This function is from https://github.com/python/cpython/issues/67998 +# (https://bugs.python.org/file39550/deprecated_module_stacklevel.diff) and +# calculates the appropriate stacklevel for deprecations to target the +# deprecation for the caller, no matter how many internal stack frames we have +# added in the process. For example, with the deprecation warning in the +# __init__ below, the appropriate stacklevel will change depending on how deep +# the inheritance hierarchy is. +def _external_stacklevel(internal): + """Find the stacklevel of the first frame that doesn't contain any of the given internal strings + + The depth will be 1 at minimum in order to start checking at the caller of + the function that called this utility method. + """ + # Get the level of my caller's caller + level = 2 + frame = _get_frame(level) + + # Normalize the path separators: + normalized_internal = [str(Path(s)) for s in internal] + + # climb the stack frames while we see internal frames + while frame and any(s in str(Path(frame.f_code.co_filename)) for s in normalized_internal): + level += 1 + frame = frame.f_back + + # Return the stack level from the perspective of whoever called us (i.e., one level up) + return level - 1 + + +def deprecation(message, internal="jupyter_core/"): + """Generate a deprecation warning targeting the first frame that is not 'internal' + + internal is a string or list of strings, which if they appear in filenames in the + frames, the frames will be considered internal. Changing this can be useful if, for examnple, + we know that our internal code is calling out to another library. + """ + if isinstance(internal, str): + internal = [internal] + + # stack level of the first external frame from here + stacklevel = _external_stacklevel(internal) + + # The call to .warn adds one frame, so bump the stacklevel up by one + warnings.warn(message, DeprecationWarning, stacklevel=stacklevel + 1) diff --git a/pyproject.toml b/pyproject.toml index 2c3a2a1..da77339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ ] requires-python = ">=3.7" dependencies = [ + "platformdirs", "traitlets", "pywin32>=1.0 ; sys_platform == 'win32' and platform_python_implementation != 'PyPy'" ] @@ -92,4 +93,6 @@ testpaths = [ filterwarnings= [ # Fail on warnings "error", + # Expected internal warnings + "module:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", ]