Repository: snakemake/snakefmt

Issue:
Spurious additional space added between adjacent replacement fields
This is almost certainly closely related to #220, but when an `f`-string contains two adjacent replacement fields (e.g. `f"{var1}{var2}"`) then `snakefmt` will insert a space between them (i.e. `f"{var1} {var2}"`).

Since in general filenames are sensitive to having extra characters inserted, this means that a working rule is broken by this change.

This occurs with Python 3.12 but not Python 3.11.

Discussion:
@mbhall88: Urgh that is quite nasty. I'll try and get to around to this ASAP@edbennett: Thanks! To confirm, #223 correctly fixes the errant behaviour in the case that was causing me issues.

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8bdf1d3..e7e014a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,7 @@ snakefmt = 'snakefmt.snakefmt:main'
 [tool.poetry.dependencies]
 python = "^3.8.1"
 click = "^8.0.0"
-black = "^24.1.1"
+black = "^24.3.0"
 toml = "^0.10.2"
 importlib_metadata = {version = ">=1.7.0,<5.0", python = "<3.8"}
 
diff --git a/snakefmt/config.py b/snakefmt/config.py
index 7de751e..f3a9213 100644
--- a/snakefmt/config.py
+++ b/snakefmt/config.py
@@ -3,12 +3,13 @@ Code for searching for and parsing snakefmt configuration files
 """
 
 from dataclasses import fields
+from functools import lru_cache
 from pathlib import Path
-from typing import Dict, Optional, Sequence, Union
+from typing import Dict, Optional, Sequence, Tuple, Union
 
 import click
 import toml
-from black import Mode, find_project_root
+from black import Mode
 
 from snakefmt import DEFAULT_LINE_LENGTH, DEFAULT_TARGET_VERSIONS
 from snakefmt.exceptions import MalformattedToml
@@ -16,6 +17,57 @@ from snakefmt.exceptions import MalformattedToml
 PathLike = Union[Path, str]
 
 
+@lru_cache
+def find_project_root(
+    srcs: Sequence[str], stdin_filename: Optional[str] = None
+) -> Tuple[Path, str]:
+    """Return a directory containing .git, .hg, or pyproject.toml.
+
+    That directory will be a common parent of all files and directories
+    passed in `srcs`.
+
+    If no directory in the tree contains a marker that would specify it's the
+    project root, the root of the file system is returned.
+
+    Returns a two-tuple with the first element as the project root path and
+    the second element as a string describing the method by which the
+    project root was discovered.
+
+    Note: taken directly from black v24.1.0 as they changed the behaviour of this
+    function in v24.2.0 to only find the root if the pyproject.toml file contained the
+    [tool.black] section. This is not the desired behaviour for snakefmt
+    """
+    if stdin_filename is not None:
+        srcs = tuple(stdin_filename if s == "-" else s for s in srcs)
+    if not srcs:
+        srcs = [str(Path.cwd().resolve())]
+
+    path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs]
+
+    # A list of lists of parents for each 'src'. 'src' is included as a
+    # "parent" of itself if it is a directory
+    src_parents = [
+        list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
+    ]
+
+    common_base = max(
+        set.intersection(*(set(parents) for parents in src_parents)),
+        key=lambda path: path.parts,
+    )
+
+    for directory in (common_base, *common_base.parents):
+        if (directory / ".git").exists():
+            return directory, ".git directory"
+
+        if (directory / ".hg").is_dir():
+            return directory, ".hg directory"
+
+        if (directory / "pyproject.toml").is_file():
+            return directory, "pyproject.toml"
+
+    return directory, "file system root"
+
+
 def find_pyproject_toml(start_path: Sequence[str]) -> Optional[str]:
     root, _ = find_project_root(start_path)
     config_file = root / "pyproject.toml"
diff --git a/snakefmt/parser/syntax.py b/snakefmt/parser/syntax.py
index c5e9bd7..55fb879 100644
--- a/snakefmt/parser/syntax.py
+++ b/snakefmt/parser/syntax.py
@@ -72,6 +72,8 @@ def operator_skip_spacing(prev_token: Token, token: Token) -> bool:
         return True
     elif prev_token.type == tokenize.STRING and token.string == ",":
         return True
+    elif prev_token.string == "}" and token.string == "{":  # issue #220
+        return True
     else:
         return False
 


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index cc89b45..2ee21d6 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -25,7 +25,8 @@ def test_black_and_snakefmt_default_line_lengths_aligned():
 class TestFindPyprojectToml:
     def test_find_pyproject_toml_nested_directory(self, tmp_path):
         config_file = (tmp_path / "pyproject.toml").resolve()
-        config_file.touch()
+        # add [tool.black] to TOML to ensure black finds it (new in black v24.2.0)
+        config_file.write_text("[tool.black]\n")
         dir1 = Path(tmp_path / "dir1").resolve()
         dir1.mkdir()
         snakefile = dir1 / "Snakefile"
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index 2ddc05f..23937a7 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -861,9 +861,6 @@ rule a:
 
     def test_f_string_with_double_braces_in_python_code(self):
         """https://github.com/snakemake/snakefmt/issues/215"""
-        """def get_test_regions(wildcards):
-    benchmark = config["variant-calls"][wildcards.callset]["benchmark"]
-    return f"resources/regions/{benchmark}/test-regions.cov-{{cov}}.bed"""
         snakecode = (
             "def get_test_regions(wildcards):\n"
             f'{TAB * 1}benchmark = config["variant-calls"][wildcards.callset]["benchmark"]\n'  # noqa: E501
@@ -872,6 +869,12 @@ rule a:
         formatter = setup_formatter(snakecode)
         assert formatter.get_formatted() == snakecode
 
+    def test_f_string_spacing_of_consecutive_braces(self):
+        """https://github.com/snakemake/snakefmt/issues/222"""
+        snakecode = 'f"{var1}{var2}"\n'
+        formatter = setup_formatter(snakecode)
+        assert formatter.get_formatted() == snakecode
+
 
 class TestReformatting_SMK_BREAK:
     """