Skip to content

Commit

Permalink
feat: Resolve relative URIs in nitpick.styles.include
Browse files Browse the repository at this point in the history
- Refactored fetchers to operate on furl parsed objects
- Relative URIs are resolved against the URI of the style file they are
  contained in.
- During normalization, `gh://` is mapped to `github://`, and
  `pypackge://` is mapped to `py://` to avoid loading styles more than
  once.
  • Loading branch information
mjpieters committed Mar 23, 2022
1 parent 610a884 commit 9016205
Show file tree
Hide file tree
Showing 13 changed files with 387 additions and 444 deletions.
24 changes: 23 additions & 1 deletion docs/nitpick_section.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ Example of usage: :gitref:`Nitpick's default style <nitpick-style.toml>`.
[nitpick.styles]
include = ["styles/python37", "styles/poetry"]
The styles will be merged following the sequence in the list.
The styles will be merged following the sequence in the list. The ``.toml``
extension for each referenced file can be onitted.

Relative references are resolved relative to the URI of the style doument they
are included in according to the `normal rules of RFC 3986 <https://www.rfc-editor.org/rfc/rfc3986.html#section-5.2>`_.

E.g. for a style file located at
``gh://$GITHUB_TOKEN@foo_dev/bar_project@branchname/styles/foobar.toml`` the following
strings all reference the exact same canonical location to include:

.. code-block:: toml
[nitpick.styles]
include = [
"foobar.toml",
"../styles/foobar.toml",
"/bar_project@branchname/styles/foobar.toml",
"//$GITHUB_TOKEN@foo_dev/bar_project@branchname/styles/foobar.toml",
]
For style files on the local filesystem, the canonical path
(after symbolic links have been resolved) of the style file is used as the
base.

If a key/value pair appears in more than one sub-style, it will be overridden; the last declared key/pair will prevail.
41 changes: 17 additions & 24 deletions src/nitpick/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
"""
from __future__ import annotations

import re
import sys
from pathlib import Path
from typing import Iterable

from furl import Path as FurlPath

from nitpick.constants import DOT, PROJECT_NAME
from nitpick.typedefs import PathOrStr

URL_RE = re.compile(r"[a-z]+://[\$\w]\w*")


def version_to_tuple(version: str = None) -> tuple[int, ...]:
"""Transform a version number into a tuple of integers, for comparison.
Expand Down Expand Up @@ -43,27 +43,6 @@ def version_to_tuple(version: str = None) -> tuple[int, ...]:
return tuple(int(part) for part in clean_version.split(DOT))


def is_url(url: str) -> bool:
"""Return True if a string is a URL.
>>> is_url("")
False
>>> is_url(" ")
False
>>> is_url("http://example.com")
True
>>> is_url("github://andreoliwa/nitpick/styles/black")
True
>>> is_url("github://$VARNAME@andreoliwa/nitpick/styles/black")
True
>>> is_url("github://LitErAl@andreoliwa/nitpick/styles/black")
True
>>> is_url("github://notfirst$@andreoliwa/nitpick/styles/black")
True
"""
return bool(URL_RE.match(url))


def relative_to_current_dir(path_or_str: PathOrStr | None) -> str:
"""Return a relative path to the current dir or an absolute path."""
if not path_or_str:
Expand Down Expand Up @@ -108,3 +87,17 @@ def filter_names(iterable: Iterable, *partial_names: str) -> list[str]:
if include:
rv.append(name)
return rv


if sys.platform == "win32":

def furl_path_to_python_path(path: FurlPath) -> Path:
"""Convert a file URL to a path."""
drive, *segments = path.segments
return Path(f"{drive}/", *segments)

else:

def furl_path_to_python_path(path: FurlPath) -> Path:
"""Convert a file URL to a path."""
return Path("/", *path.segments)
41 changes: 24 additions & 17 deletions src/nitpick/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@

import itertools
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Iterable, Iterator

import pluggy
from autorepr import autorepr
from loguru import logger
from marshmallow_polyfield import PolyField
Expand Down Expand Up @@ -36,22 +34,22 @@
from nitpick.exceptions import QuitComplainingError
from nitpick.generic import version_to_tuple
from nitpick.schemas import BaseNitpickSchema, flatten_marshmallow_errors, help_message
from nitpick.typedefs import JsonDict, PathOrStr, mypy_property
from nitpick.typedefs import JsonDict, PathOrStr
from nitpick.violations import Fuss, ProjectViolations, Reporter, StyleViolations


def glob_files(dir_: Path, file_patterns: Iterable[str]) -> set[Path]:
"""Search a directory looking for file patterns."""
for pattern in file_patterns:
found_files = list(dir_.glob(pattern))
found_files = set(dir_.glob(pattern))
if found_files:
return set(found_files)
return found_files
return set()


def confirm_project_root(dir_: PathOrStr | None = None) -> Path:
"""Confirm this is the root dir of the project (the one that has one of the ``ROOT_FILES``)."""
possible_root_dir = Path(dir_ or Path.cwd())
possible_root_dir = Path(dir_ or Path.cwd()).resolve()
root_files = glob_files(possible_root_dir, ROOT_FILES)
logger.debug(f"Root files found: {root_files}")

Expand Down Expand Up @@ -110,27 +108,35 @@ class Project:

__repr__ = autorepr(["_chosen_root", "root"])

_plugin_manager: PluginManager
_confirmed_root: Path

def __init__(self, root: PathOrStr = None) -> None:
self._chosen_root = root

self.style_dict: JsonDict = {}
self.nitpick_section: JsonDict = {}
self.nitpick_files_section: JsonDict = {}

@mypy_property
@lru_cache()
@property
def root(self) -> Path:
"""Root dir of the project."""
return confirm_project_root(self._chosen_root)
try:
root = self._confirmed_root
except AttributeError:
root = self._confirmed_root = confirm_project_root(self._chosen_root)
return root

@mypy_property
@lru_cache()
@property
def plugin_manager(self) -> PluginManager:
"""Load all defined plugins."""
plugin_manager = pluggy.PluginManager(PROJECT_NAME)
plugin_manager.add_hookspecs(plugins)
plugin_manager.load_setuptools_entrypoints(PROJECT_NAME)
return plugin_manager
try:
manager = self._plugin_manager
except AttributeError:
manager = self._plugin_manager = PluginManager(PROJECT_NAME)
manager.add_hookspecs(plugins)
manager.load_setuptools_entrypoints(PROJECT_NAME)
return manager

def read_configuration(self) -> Configuration:
"""Search for a configuration file and validate it against a Marshmallow schema."""
Expand Down Expand Up @@ -175,7 +181,8 @@ def merge_styles(self, offline: bool) -> Iterator[Fuss]:
from nitpick.style import StyleManager

style = StyleManager(self, offline, config.cache)
style_errors = list(style.find_initial_styles(peekable(always_iterable(config.styles))))
base = config.file.expanduser().resolve().as_uri() if config.file else None
style_errors = list(style.find_initial_styles(peekable(always_iterable(config.styles)), base))
if style_errors:
raise QuitComplainingError(style_errors)

Expand Down Expand Up @@ -209,7 +216,7 @@ def create_configuration(self, config: Configuration) -> None:
tool_nitpick = table()
tool_nitpick.add(comment("Generated by the 'nitpick init' command"))
tool_nitpick.add(comment(f"More info at {READ_THE_DOCS_URL}configuration.html"))
tool_nitpick.add("style", [StyleManager.get_default_style_url()])
tool_nitpick.add("style", [str(StyleManager.get_default_style_url())])
doc.add(SingleKey(TOOL_NITPICK_KEY, KeyType.Bare), tool_nitpick)

# config.file will always have a value at this point, but mypy can't see it.
Expand Down
Loading

0 comments on commit 9016205

Please sign in to comment.