diff --git a/changelog.txt b/changelog.txt index e5b1f2ad2..40ffc7106 100644 --- a/changelog.txt +++ b/changelog.txt @@ -14,6 +14,7 @@ Changed +++++++ - linked views now can contain spaces and other characters except directory separators (#926). + - linked views now can be created on Windows, if 'Developer mode' is enabled (#430). [2.1.0] -- 2023-07-12 --------------------- diff --git a/contributors.yaml b/contributors.yaml index 5ba517c87..a611392c7 100644 --- a/contributors.yaml +++ b/contributors.yaml @@ -138,4 +138,8 @@ contributors: family-names: Kadar given-names: Alain affiliation: "University of Michigan" + - + family-names: Stoimenov + given-names: Boyko + affiliation: "JTEKT Corp." ... diff --git a/signac/linked_view.py b/signac/linked_view.py index ab273a721..ad941cef7 100644 --- a/signac/linked_view.py +++ b/signac/linked_view.py @@ -7,6 +7,7 @@ import logging import os import sys +import textwrap from itertools import chain from ._utility import _mkdir_p @@ -38,21 +39,15 @@ def create_linked_view(project, prefix=None, job_ids=None, path=None): Raises ------ OSError - Linked views cannot be created on Windows because - symbolic links are not supported by the platform. + If symbolic links are not enabled on Windows, + linked views cannot be created. + RuntimeError When state points contain ``os.sep``. """ from .import_export import _check_directory_structure_validity, _make_path_function - # Windows does not support the creation of symbolic links. - if sys.platform == "win32": - raise OSError( - "signac cannot create linked views on Windows, because " - "symbolic links are not supported by the platform." - ) - if prefix is None: prefix = "view" @@ -85,8 +80,34 @@ def create_linked_view(project, prefix=None, job_ids=None, path=None): for job in project.find_jobs(): links["./job"] = job.path assert len(links) < 2 - _check_directory_structure_validity(links.keys()) - _update_view(prefix, links) + + # Updating the view will fail on Windows, if symlinks are not enabled. + # Before re-raising the exception, print a helpful message for the expected error. + try: + _check_directory_structure_validity(links.keys()) + _update_view(prefix, links) + except OSError as err: + if sys.platform == "win32" and err.winerror == 1314: + print( + textwrap.dedent( + f"""\ + ------------------------------------------------------------------- + Error: + {err.strerror} + + You may not have permission to create Windows symlinks. + To enable the creation of symlinks on Windows you need + to enable 'Developer mode' (requires administrative rights). + To enable 'Developer mode': + 1. Go to 'Settings'. + 2. In the search bar type 'Use developer features'. + 3. Enable the item 'Developer mode'. + The details for Home edition and between Windows versions may vary. + ------------------------------------------------------------------- + """ + ) + ) + raise err.with_traceback(sys.exc_info()[2]) return links diff --git a/tests/test_project.py b/tests/test_project.py index 565a2d5bc..da546ede0 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,6 +1,7 @@ # Copyright (c) 2017 The Regents of the University of Michigan # All rights reserved. # This software is licensed under the BSD 3-Clause License. +import functools import gzip import io import json @@ -61,10 +62,31 @@ except ImportError: NUMPY = False -# Skip linked view tests on Windows WINDOWS = sys.platform == "win32" +@functools.lru_cache +def _check_symlinks_supported(): + """Check if symlinks are supported on the current platform.""" + try: + with TemporaryDirectory() as tmp_dir: + os.symlink( + os.path.realpath(__file__), os.path.join(tmp_dir, "test_symlink") + ) + return True + except (NotImplementedError, OSError): + return False + + +def skip_windows_without_symlinks(test_func): + """Skip test if platform is Windows and symlinks are not supported.""" + + return pytest.mark.skipif( + WINDOWS and not _check_symlinks_supported(), + reason="Symbolic links are unsupported on Windows unless in Developer Mode.", + )(test_func) + + class TestProjectBase(TestJobBase): pass @@ -188,7 +210,7 @@ def test_no_workspace_warn_on_find(self, caplog): # constructor: https://bugs.python.org/issue33234 assert len(caplog.records) in (2, 3) - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_workspace_broken_link_error_on_find(self): with TemporaryDirectory() as tmp_dir: project = self.project_class.init_project(path=tmp_dir) @@ -1534,7 +1556,7 @@ def test_Schema_repr_methods(self, project_generator, num_jobs): class TestLinkedViewProject(TestProjectBase): - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view(self): def clean(filter=None): """Helper function for wiping out views""" @@ -1620,7 +1642,7 @@ def clean(filter=None): src = set(map(lambda j: os.path.realpath(j.path), self.project.find_jobs())) assert src == dst - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_tree(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(10) @@ -1650,7 +1672,7 @@ def test_create_linked_view_homogeneous_schema_tree(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_tree_tree(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(10) @@ -1680,7 +1702,7 @@ def test_create_linked_view_homogeneous_schema_tree_tree(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_tree_flat(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(10) @@ -1707,7 +1729,7 @@ def test_create_linked_view_homogeneous_schema_tree_flat(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_flat_flat(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(10) @@ -1734,7 +1756,7 @@ def test_create_linked_view_homogeneous_schema_flat_flat(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_flat_tree(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(10) @@ -1769,7 +1791,7 @@ def test_create_linked_view_homogeneous_schema_flat_tree(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_nested(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(2) @@ -1801,7 +1823,7 @@ def test_create_linked_view_homogeneous_schema_nested(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_homogeneous_schema_nested_provide_partial_path(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(2) @@ -1841,7 +1863,7 @@ def test_create_linked_view_homogeneous_schema_nested_provide_partial_path(self) ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_disjoint_schema(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(5) @@ -1871,7 +1893,7 @@ def test_create_linked_view_heterogeneous_disjoint_schema(self): os.path.join(view_prefix, "c", sp["c"], "a", str(sp["a"]), "job") ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_disjoint_schema_nested(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(2) @@ -1902,7 +1924,7 @@ def test_create_linked_view_heterogeneous_disjoint_schema_nested(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_fizz_schema_flat(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(5) @@ -1943,7 +1965,7 @@ def test_create_linked_view_heterogeneous_fizz_schema_flat(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_schema_nested(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(5) @@ -1979,7 +2001,7 @@ def test_create_linked_view_heterogeneous_schema_nested(self): ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_schema_nested_partial_homogenous_path_provide( self, ): @@ -2028,7 +2050,7 @@ def test_create_linked_view_heterogeneous_schema_nested_partial_homogenous_path_ ) ) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_heterogeneous_schema_problematic(self): self.project.open_job(dict(a=1)).init() self.project.open_job(dict(a=1, b=1)).init() @@ -2036,7 +2058,7 @@ def test_create_linked_view_heterogeneous_schema_problematic(self): with pytest.raises(RuntimeError): self.project.create_linked_view(view_prefix) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_with_slash_raises_error(self): statepoint = {"b": f"bad{os.sep}val"} view_prefix = os.path.join(self._tmp_pr, "view") @@ -2044,9 +2066,11 @@ def test_create_linked_view_with_slash_raises_error(self): with pytest.raises(RuntimeError): self.project.create_linked_view(prefix=view_prefix) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_weird_chars_in_file_name(self): - shell_escaped_chars = [" ", "*", "~"] + shell_escaped_chars = [" ", "~"] + if not WINDOWS: + shell_escaped_chars.append("*") statepoints = [ {f"a{i}b": 0, "b": f"escaped{i}val"} for i in shell_escaped_chars ] @@ -2055,7 +2079,7 @@ def test_create_linked_view_weird_chars_in_file_name(self): self.project.open_job(sp).init() self.project.create_linked_view(prefix=view_prefix) - @pytest.mark.skipif(WINDOWS, reason="Linked views unsupported on Windows.") + @skip_windows_without_symlinks def test_create_linked_view_duplicate_paths(self): view_prefix = os.path.join(self._tmp_pr, "view") a_vals = range(2) @@ -2268,7 +2292,7 @@ def test_get_job_nested_project_subdir(self): assert project.get_job(job.fn("test_subdir")) == job assert signac.get_job(job.fn("test_subdir")) == job - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_get_job_symlink_other_project(self): # Test case: Get a job from a symlink in another project workspace path = self._tmp_dir.name diff --git a/tests/test_shell.py b/tests/test_shell.py index 073223e71..695fdada1 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -9,14 +9,11 @@ from tempfile import TemporaryDirectory import pytest -from test_project import _initialize_v1_project +from test_project import WINDOWS, _initialize_v1_project, skip_windows_without_symlinks import signac from signac._config import USER_CONFIG_FN, _Config, _load_config, _read_config_file -# Skip linked view tests on Windows -WINDOWS = sys.platform == "win32" - class DummyFile: "We redirect sys stdout into this file during console tests." @@ -154,7 +151,7 @@ def test_document(self): assert str(key) in out assert str(value) in out - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_view_single(self): """Check whether command line views work for single job workspaces.""" self.call("python -m signac init".split()) @@ -170,7 +167,7 @@ def test_view_single(self): project.open_job(sp).path ) - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_view(self): self.call("python -m signac init".split()) project = signac.Project() @@ -186,7 +183,7 @@ def test_view(self): "view/a/{}/job".format(sp["a"]) ) == os.path.realpath(project.open_job(sp).path) - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_view_prefix(self): self.call("python -m signac init".split()) project = signac.Project() @@ -202,7 +199,7 @@ def test_view_prefix(self): "view/test_dir/a/{}/job".format(sp["a"]) ) == os.path.realpath(project.open_job(sp).path) - @pytest.mark.skipif(WINDOWS, reason="Symbolic links are unsupported on Windows.") + @skip_windows_without_symlinks def test_view_incomplete_path_spec(self): self.call("python -m signac init".split()) project = signac.Project()