diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index b54760a6f..2da6ef9ce 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -30,6 +30,19 @@ 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. + - 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: + 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/porcupine/plugins/git_status.py b/porcupine/plugins/git_status.py index da6211933..e1181f98e 100644 --- a/porcupine/plugins/git_status.py +++ b/porcupine/plugins/git_status.py @@ -1,6 +1,7 @@ """Color items in the directory tree based on their git status.""" from __future__ import annotations +import ast import logging import os import subprocess @@ -28,6 +29,36 @@ git_pool = ThreadPoolExecutor(max_workers=os.cpu_count()) +# Assuming utf-8 file system encoding, when git means örkkiäinen.txt, +# it actually outputs "\303\266rkki\303\244inen.txt" with the quotes. +# +# The simplest way to parse this seems to be treating it as a Python +# byte string: +# +# >>> 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('"'): + # 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. + # + # 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) + + def run_git_status(project_root: Path) -> dict[Path, str]: try: start = time.perf_counter() @@ -57,7 +88,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/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: 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() 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)