From a2ca2cd7b6a2c90924a0a00121bb27db6bab7b6e Mon Sep 17 00:00:00 2001 From: Akuli Date: Fri, 13 Oct 2023 23:05:34 +0300 Subject: [PATCH 1/6] Fix handling of weird file names in git_status plugin (#1408) --- porcupine/plugins/git_status.py | 45 ++++++++++++++++++++++++++++++++- tests/test_git_status_plugin.py | 33 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/porcupine/plugins/git_status.py b/porcupine/plugins/git_status.py index da6211933..b1efc0ea9 100644 --- a/porcupine/plugins/git_status.py +++ b/porcupine/plugins/git_status.py @@ -3,6 +3,7 @@ import logging import os +import re import subprocess import sys import time @@ -28,6 +29,48 @@ git_pool = ThreadPoolExecutor(max_workers=os.cpu_count()) +# Assuming utf-8 file system encoding, git outputs "\303\266rkki\303\244inen.txt" +# with the quotes when it means örkkiäinen.txt. +# +# The \xxx means byte xxx specified as octal. First digit is 0-3 because the +# biggest possible byte value is 255, which is 0o377 octal. +# +# Because this would be too easy, Git also special-cases some characters. For +# example, tabs come out as \t rather than \011. +_SPECIAL_ESCAPES = { + # There are probably more, but hopefully this covers everything + # that comes up in real-world projects + b"\\t": b"\t", # \t = tab + b"\\r": b"\r", # \r = CR byte (part of CRLF newline: \r\n) + b"\\n": b"\n", # \n = newline + b'\\"': b'"', # \" = quote + b"\\\\": b"\\", # \\ = literal backslash (not path separator) +} +_ESCAPE_REGEX = rb"\\[0-3][0-7][0-7]|" + b"|".join(map(re.escape, _SPECIAL_ESCAPES.keys())) + + +def _handle_special_git_escape(match: re.Match[bytes]) -> bytes: + try: + return _SPECIAL_ESCAPES[match.group(0)] + except KeyError: + # b"\123" --> bytes([0o123]) + return bytes([int(match.group(0)[1:], 8)]) + + +def _parse_ascii_path_from_git(ascii_str: str) -> Path: + assert ascii_str.isascii() + + if ascii_str.startswith('"') and ascii_str.endswith('"'): + path_bytes = ascii_str[1:-1].encode("ascii") + path_bytes = re.sub(_ESCAPE_REGEX, _handle_special_git_escape, path_bytes) + + # Avoid encoding errors, so that a weird file name will not prevent + # other files from working properly + return Path(path_bytes.decode(sys.getfilesystemencoding(), errors="replace")) + else: + return Path(ascii_str) + + def run_git_status(project_root: Path) -> dict[Path, str]: try: start = time.perf_counter() @@ -57,7 +100,7 @@ def run_git_status(project_root: Path) -> dict[Path, str]: # Show .git as ignored, even though it actually isn't result = {project_root / ".git": "git_ignored"} for line in run_result.stdout.splitlines(): - path = project_root / line[3:] + path = project_root / _parse_ascii_path_from_git(line[3:]) if line[1] == "M": result[path] = "git_modified" elif line[1] == " ": diff --git a/tests/test_git_status_plugin.py b/tests/test_git_status_plugin.py index 598011328..806385fdc 100644 --- a/tests/test_git_status_plugin.py +++ b/tests/test_git_status_plugin.py @@ -1,11 +1,13 @@ import shutil import subprocess +import sys from functools import partial from pathlib import Path import pytest from porcupine import get_tab_manager +from porcupine.plugins.git_status import run_git_status @pytest.mark.skipif(shutil.which("git") is None, reason="git not found") @@ -29,6 +31,37 @@ def test_added_and_modified_content(tree, tmp_path, monkeypatch): assert set(tree.item(project_id, "tags")) == {"git_added"} +weird_filenames = ["foo bar.txt", "foo'bar.txt", "örkkimörkkiäinen.ö", "bigyó.txt", "2π.txt"] +if sys.platform != "win32": + # Test each "Windows-forbidden" character: https://stackoverflow.com/a/31976060 + weird_filenames += [ + "foobar.txt", + "foo:bar.txt", + 'foo"bar.txt', + r"foo\bar.txt", + r"foo\123.txt", # not a special escape code, only looks like it + "foo|bar.txt", + "foo?bar.txt", + "foo*bar.txt", + # Not mentioned in linked stackoverflow answer but still doesn't work on Windows + "foo\tbar.txt", + "foo\nbar.txt", + "foo\rbar.txt", + ] + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git not found") +@pytest.mark.parametrize("filename", weird_filenames) +def test_funny_paths(tmp_path, filename): + (tmp_path / filename).write_text("blah") + subprocess.check_call(["git", "init", "--quiet"], cwd=tmp_path, stdout=subprocess.DEVNULL) + subprocess.check_call(["git", "add", "."], cwd=tmp_path) + + statuses = run_git_status(tmp_path) + assert statuses[tmp_path / filename] == "git_added" + + @pytest.mark.skipif(shutil.which("git") is None, reason="git not found") def test_merge_conflict(tree, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 4808c6e7efb8a4e67fa5565a6191c222fe172e94 Mon Sep 17 00:00:00 2001 From: Akuli Date: Fri, 13 Oct 2023 23:26:00 +0300 Subject: [PATCH 2/6] Fix nsis download link again (#1414) --- scripts/build-exe-installer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/build-exe-installer.py b/scripts/build-exe-installer.py index 27fd3ca4e..2a222204a 100644 --- a/scripts/build-exe-installer.py +++ b/scripts/build-exe-installer.py @@ -45,11 +45,11 @@ zipfile.ZipFile(io.BytesIO(response.content)).extractall("build/python-first") print("Downloading NSIS") -# sourceforge broke their download link. Fortunately I found the same file -# from a different site by googling the file name "nsis-3.08.zip". -# There were also a few copies of it in my downloads folder. -# url = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.08/nsis-3.08.zip" -url = "https://fossies.org/windows/misc/nsis-3.08.zip" +# Sometimes these links break. +# If they are all simultaneously broken, search the file name on google. +# There are also a few copies in Akuli's downloads folder. +# url = "https://fossies.org/windows/misc/nsis-3.08.zip" +url = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.08/nsis-3.08.zip" print(url) response = requests.get(url) response.raise_for_status() From 5cb0a8720079bc4d382e04201f1480c5b2a61a7b Mon Sep 17 00:00:00 2001 From: Richard Lawson Date: Sat, 14 Oct 2023 18:05:28 -0400 Subject: [PATCH 3/6] Feature/tooltips offscreen (#1412) --- porcupine/utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/porcupine/utils.py b/porcupine/utils.py index 8be70341d..c176332cd 100644 --- a/porcupine/utils.py +++ b/porcupine/utils.py @@ -167,7 +167,6 @@ def show(self) -> None: if self.got_mouse: self.destroy_tipwindow() tipwindow = type(self).tipwindow = tkinter.Toplevel() - tipwindow.geometry(f"+{self.mousex + 10}+{self.mousey - 10}") tipwindow.bind("", self.destroy_tipwindow, add=True) tipwindow.overrideredirect(True) @@ -176,6 +175,15 @@ def show(self) -> None: # the label will have light text on a light background or # dark text on a dark background on some systems. tkinter.Label(tipwindow, text=self.text, border=3, fg="black", bg="white").pack() + tipwindow.update_idletasks() + screen_width = tipwindow.winfo_screenwidth() + to_end = screen_width - self.mousex + tip_width = tipwindow.winfo_width() + if to_end >= tip_width / 2: + offset = int(tip_width / 2) + else: + offset = int(tip_width - to_end) + tipwindow.geometry(f"+{self.mousex - offset}+{self.mousey - 30}") def set_tooltip(widget: tkinter.Widget, text: str) -> None: From 3c5a93856ad33fe9d63dde550fb843696afd2278 Mon Sep 17 00:00:00 2001 From: Akuli Date: Wed, 1 Nov 2023 23:30:33 +0200 Subject: [PATCH 4/6] Simplify "git status" handling with a hack (#1416) Co-authored-by: Akuli --- porcupine/plugins/git_status.py | 48 +++++++++++++-------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/porcupine/plugins/git_status.py b/porcupine/plugins/git_status.py index b1efc0ea9..e1181f98e 100644 --- a/porcupine/plugins/git_status.py +++ b/porcupine/plugins/git_status.py @@ -1,9 +1,9 @@ """Color items in the directory tree based on their git status.""" from __future__ import annotations +import ast import logging import os -import re import subprocess import sys import time @@ -29,43 +29,31 @@ git_pool = ThreadPoolExecutor(max_workers=os.cpu_count()) -# Assuming utf-8 file system encoding, git outputs "\303\266rkki\303\244inen.txt" -# with the quotes when it means örkkiäinen.txt. +# Assuming utf-8 file system encoding, when git means örkkiäinen.txt, +# it actually outputs "\303\266rkki\303\244inen.txt" with the quotes. # -# The \xxx means byte xxx specified as octal. First digit is 0-3 because the -# biggest possible byte value is 255, which is 0o377 octal. +# The simplest way to parse this seems to be treating it as a Python +# byte string: # -# Because this would be too easy, Git also special-cases some characters. For -# example, tabs come out as \t rather than \011. -_SPECIAL_ESCAPES = { - # There are probably more, but hopefully this covers everything - # that comes up in real-world projects - b"\\t": b"\t", # \t = tab - b"\\r": b"\r", # \r = CR byte (part of CRLF newline: \r\n) - b"\\n": b"\n", # \n = newline - b'\\"': b'"', # \" = quote - b"\\\\": b"\\", # \\ = literal backslash (not path separator) -} -_ESCAPE_REGEX = rb"\\[0-3][0-7][0-7]|" + b"|".join(map(re.escape, _SPECIAL_ESCAPES.keys())) - - -def _handle_special_git_escape(match: re.Match[bytes]) -> bytes: - try: - return _SPECIAL_ESCAPES[match.group(0)] - except KeyError: - # b"\123" --> bytes([0o123]) - return bytes([int(match.group(0)[1:], 8)]) - - +# >>> eval(r'b"\303\266rkki\303\244inen.txt"') +# b'\xc3\xb6rkki\xc3\xa4inen.txt' +# >>> eval(r'b"\303\266rkki\303\244inen.txt"').decode("utf-8") +# 'örkkiäinen.txt' +# +# This works because git's weird quoting is apparently close enough +# to Python's string syntax. def _parse_ascii_path_from_git(ascii_str: str) -> Path: assert ascii_str.isascii() if ascii_str.startswith('"') and ascii_str.endswith('"'): - path_bytes = ascii_str[1:-1].encode("ascii") - path_bytes = re.sub(_ESCAPE_REGEX, _handle_special_git_escape, path_bytes) + # ast.literal_eval() is a safe/restricted version of the usual eval() + path_bytes = ast.literal_eval("b" + ascii_str) # Avoid encoding errors, so that a weird file name will not prevent - # other files from working properly + # other files from working properly. + # + # TODO: sys.getfilesystemencoding() seems to always be UTF-8, even + # on Windows, so not sure if this should always use utf-8 return Path(path_bytes.decode(sys.getfilesystemencoding(), errors="replace")) else: return Path(ascii_str) From d159dc3235fe624915ef60c41f85bdd3c5f3095a Mon Sep 17 00:00:00 2001 From: Akuli Date: Wed, 1 Nov 2023 23:52:16 +0200 Subject: [PATCH 5/6] Put dev dependencies to pyproject.toml and auto-generate requirements-dev.txt --- .github/workflows/autofix.yml | 11 +++++++++++ pyproject.toml | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 6f25a25c6..3e55fdfa2 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -30,6 +30,17 @@ jobs: - run: python3 -m pyupgrade $(cat filelist.txt) --keep-runtime-typing --py38-plus --exit-zero-even-if-changed - run: python3 -m black $(cat filelist.txt) - run: python3 -m isort $(cat filelist.txt) + # I like requirements-dev.txt and some other people like pyproject.toml, so we have both. + # Auto-generating is the easiest way to ensure the two stay in sync. + - run: | + echo "# Auto-generated in GitHub Actions. See autofix.yml." > pr/requirements-dev.txt + python3 -c 'if True: + import tomli + with open("pr/pyproject.toml", "rb") as f: + content = tomli.load(f) + for dep in content["project"]["optional-dependencies"]["dev"]: + print(dep) + ' >> pr/requirements-dev.txt - uses: stefanzweifel/git-auto-commit-action@v4 with: repository: ./pr diff --git a/pyproject.toml b/pyproject.toml index ad5f19951..eab4a5e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,33 @@ dependencies = [ "sv-ttk>=2.5.5", ] +[project.optional-dependencies] +dev = [ + "pytest==6.2.5", + "pytest-cov==4.0.0", + "pytest-mock==3.10.0", + "pycln==2.1.3", + "black==23.1.0", + "isort==5.12.0", + "pyupgrade==3.9.0", + "sphinx>=4.0.0, <5.0.0", + "pillow>=5.4.1", + "requests>=2.24.0, <3.0.0", + + # type checking, exact versions to avoid "works on my computer" problems + "mypy==1.1.1", + "types-Pygments==2.14.0.6", + "types-docutils==0.19.1.6", + "types-Send2Trash==1.8.2.4", + "types-setuptools==67.6.0.5", + "types-colorama==0.4.15.8", + "types-toposort==1.10.0.0", + "types-psutil==5.9.5.10", + "types-PyYAML==6.0.12.8", + "types-tree-sitter==0.20.1.2", + "types-tree-sitter-languages==1.5.0.2", +] + [project.scripts] porcu = "porcupine.__main__:main" porcupine = "porcupine.__main__:main" From 938e883cb7dad8036c2d452cc3046496d0edd960 Mon Sep 17 00:00:00 2001 From: Akuli Date: Wed, 1 Nov 2023 23:57:29 +0200 Subject: [PATCH 6/6] Fix requirements-dev.txt generation --- .github/workflows/autofix.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 3e55fdfa2..b8c7a8dc0 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -32,8 +32,10 @@ jobs: - run: python3 -m isort $(cat filelist.txt) # I like requirements-dev.txt and some other people like pyproject.toml, so we have both. # Auto-generating is the easiest way to ensure the two stay in sync. - - run: | + - name: Generate requirements-dev.txt from pyproject.toml + run: | echo "# Auto-generated in GitHub Actions. See autofix.yml." > pr/requirements-dev.txt + pip install tomli python3 -c 'if True: import tomli with open("pr/pyproject.toml", "rb") as f: