Skip to content

Commit

Permalink
feat: read style from Python package (thanks to @isac322) (andreoliwa…
Browse files Browse the repository at this point in the history
…#407)

* build: add importlib-resources as conditional dependency
* feat: implement Python package fetcher
* docs: style inside Python package
* test: long Python package prefix

Co-authored-by: W. Augusto Andreoli <andreoliwa@gmail.com>
  • Loading branch information
isac322 and andreoliwa authored Oct 27, 2021
1 parent dc2794b commit 0a3c95d
Show file tree
Hide file tree
Showing 12 changed files with 163 additions and 2 deletions.
18 changes: 18 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ You can also use the raw URL of a `GitHub Gist <https://gist.github.com>`_:
[tool.nitpick]
style = "https://gist.githubusercontent.com/andreoliwa/f4fccf4e3e83a3228e8422c01a48be61/raw/ff3447bddfc5a8665538ddf9c250734e7a38eabb/remote-style.toml"
Style inside Python package
---------------------------

The style file can be fetched from an installed Python package.

Example of a use case: you create a custom flake8 extension and you also want to distribute a (versioned) Nitpick style bundled as a resource inside the Python package (`check out this issue: Get style file from python package · Issue #202 <https://github.com/andreoliwa/nitpick/issues/202#issuecomment-703345486>`_).

Python package URL scheme is ``pypackage://`` or ``py://``:

.. code-block:: toml
[tool.nitpick]
style = "pypackage://some_python_package.styles.nitpick-style.toml"
# or
style = "py://some_python_package.styles.nitpick-style.toml"
Thanks to `@isac322 <https://github.com/isac322>`_ for this feature.

Cache
-----

Expand Down
7 changes: 7 additions & 0 deletions docs/source/nitpick.style.fetchers.pypackage.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
nitpick.style.fetchers.pypackage module
=======================================

.. automodule:: nitpick.style.fetchers.pypackage
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/source/nitpick.style.fetchers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ Submodules
nitpick.style.fetchers.file
nitpick.style.fetchers.github
nitpick.style.fetchers.http
nitpick.style.fetchers.pypackage
36 changes: 35 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ autorepr = "*"
loguru = "*"
ConfigUpdater = "*"
cachy = "*"
importlib-resources = { version = "*", python = ">=3.6, <3.9" }

[tool.poetry.extras]
lint = ["pylint"]
Expand Down
3 changes: 2 additions & 1 deletion src/nitpick/style/fetchers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,12 @@ def _get_fetchers(cache_repository, cache_option) -> "FetchersType":
from nitpick.style.fetchers.file import FileFetcher
from nitpick.style.fetchers.github import GitHubFetcher
from nitpick.style.fetchers.http import HttpFetcher
from nitpick.style.fetchers.pypackage import PythonPackageFetcher

def _factory(klass):
return klass(cache_repository, cache_option)

fetchers = (_factory(FileFetcher), _factory(HttpFetcher), _factory(GitHubFetcher))
fetchers = (_factory(FileFetcher), _factory(HttpFetcher), _factory(GitHubFetcher), _factory(PythonPackageFetcher))
pairs = _fetchers_to_pairs(fetchers)
return dict(pairs)

Expand Down
61 changes: 61 additions & 0 deletions src/nitpick/style/fetchers/pypackage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Support for ``py`` schemes."""
from dataclasses import dataclass
from itertools import chain
from typing import Tuple
from urllib.parse import urlparse

from nitpick.style.fetchers.base import StyleFetcher

try:
from importlib.abc import Traversable # type: ignore[attr-defined]
from importlib.resources import files # type: ignore[attr-defined]
except ImportError:
from importlib_resources import files
from importlib_resources.abc import Traversable


@dataclass(unsafe_hash=True)
class PythonPackageURL:
"""Represent a resource file in installed Python package."""

import_path: str
resource_name: str

@classmethod
def parse_url(cls, url: str) -> "PythonPackageURL":
"""Create an instance by parsing a URL string in any accepted format.
See the code for ``test_parsing_python_package_urls()`` for more examples.
"""
parsed_url = urlparse(url)
package_name = parsed_url.netloc
resource_path = parsed_url.path.strip("/").split("/")

import_path = ".".join(chain((package_name,), resource_path[:-1]))
resource_name = resource_path[-1]

return cls(import_path=import_path, resource_name=resource_name)

@property
def raw_content_url(self) -> Traversable:
"""Raw path of resource file."""
return files(self.import_path).joinpath(self.resource_name)


@dataclass(repr=True, unsafe_hash=True)
class PythonPackageFetcher(StyleFetcher): # pylint: disable=too-few-public-methods
"""
Fetch a style from an installed Python package.
URL schemes:
- ``py://import/path/of/style/file/<style_file_name>``
- ``pypackage://import/path/of/style/file/<style_file_name>``
E.g. ``py://some_package/path/nitpick.toml``.
"""

protocols: Tuple[str, ...] = ("py", "pypackage")

def _do_fetch(self, url):
package_url = PythonPackageURL.parse_url(url)
return package_url.raw_content_url.read_text()
1 change: 1 addition & 0 deletions tests/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""To test ``PythonPackageURL``."""
Empty file.
1 change: 1 addition & 0 deletions tests/resources/nested_package/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""To test ``PythonPackageURL``."""
Empty file.
36 changes: 36 additions & 0 deletions tests/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from nitpick.constants import DOT_SLASH, PYPROJECT_TOML, READ_THE_DOCS_URL, SETUP_CFG, TOML_EXTENSION, TOX_INI
from nitpick.style.fetchers.github import GitHubURL
from nitpick.style.fetchers.pypackage import PythonPackageURL
from nitpick.violations import Fuss
from tests.helpers import SUGGESTION_BEGIN, SUGGESTION_END, XFAIL_ON_WINDOWS, ProjectMock, assert_conditions

Expand Down Expand Up @@ -836,6 +837,41 @@ def test_parsing_github_urls(original_url, expected_url, git_reference, raw_git_
assert gh.long_protocol_url == f"github://andreoliwa/nitpick{at_reference}/src/nitpick/__init__.py"


@pytest.mark.parametrize(
"original_url, import_path, resource_name",
[
("py://nitpick/styles/nitpick-style.toml", "nitpick.styles", "nitpick-style.toml"),
("py://some_package/nitpick.toml", "some_package", "nitpick.toml"),
("pypackage://nitpick/styles/nitpick-style.toml", "nitpick.styles", "nitpick-style.toml"),
("pypackage://some_package/nitpick.toml", "some_package", "nitpick.toml"),
],
)
def test_parsing_python_package_urls(original_url, import_path, resource_name):
"""Test a resource URL of python package and its parts."""
pp = PythonPackageURL.parse_url(original_url)
assert pp.import_path == import_path
assert pp.resource_name == resource_name


@pytest.mark.parametrize(
"original_url, expected_content_path_suffix",
[
("py://tests/resources/empty-style.toml", "tests/resources/empty-style.toml"),
("py://tests/resources/nested_package/empty_style.toml", "tests/resources/nested_package/empty_style.toml"),
("pypackage://tests/resources/empty-style.toml", "tests/resources/empty-style.toml"),
(
"pypackage://tests/resources/nested_package/empty_style.toml",
"tests/resources/nested_package/empty_style.toml",
),
],
)
def test_raw_content_url_of_python_package(original_url, expected_content_path_suffix):
"""Test ``PythonPackageURL`` can return valid path."""
pp = PythonPackageURL.parse_url(original_url)
expected_content_path = Path(__file__).parent.parent / expected_content_path_suffix
assert pp.raw_content_url == expected_content_path


def test_protocol_not_supported(tmp_path):
"""Test unsupported protocols."""
project = ProjectMock(tmp_path).pyproject_toml(
Expand Down

0 comments on commit 0a3c95d

Please sign in to comment.