Repository: MetOffice/stylist

Issue:
Add long description to pyproject.toml.
Checking our releasees highlighted that there is no long description.

Hints from issue comments:

Solution patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 873ee17..12d1737 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,6 +28,7 @@ below:
 * Matt Shin (Met Office, UK)
 * Bilal Chughtai (Met Office, UK)
 * James Cuninngham-Smith (Met Office, UK)
+* Sam Clarke-Green (Met Office, UK)
 
 (All contributors are identifiable with email addresses in the version control
 logs or otherwise.)
diff --git a/pyproject.toml b/pyproject.toml
index 0c4d5fc..7f8664b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,9 +7,16 @@ name='stylist'
 description="Flexible source code style checking tool"
 requires-python = '>=3.7, <4'
 license = {text = 'BSD 3-Clause License'}
-classifiers = ['Programming Language :: Python :: 3']
 dependencies = ['fparser >= 0.1.2']
 dynamic = ['version', 'readme']
+keywords = ['linter', 'fortran']
+classifiers = [
+    'Development Status :: 3 - Alpha',
+    'Intended Audience :: Developers',
+    'Topic :: Software Development :: Quality Assurance',
+    'License :: OSI Approved :: BSD License',
+    'Programming Language :: Python :: 3'
+]
 
 
 [project.optional-dependencies]
@@ -31,7 +38,7 @@ documentation = 'https://metoffice.github.io/stylist'
 repository = 'https://github.com/MetOffice/stylist/'
 
 [tool.setuptools.dynamic]
-readme = {file = 'README.md'}
+readme = {file = 'README.rst'}
 version = {attr = 'stylist.__version__'}
 
 [tool.setuptools.packages.find]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index e92c9a0..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,18 +0,0 @@
-[metadata]
-name = stylist
-description = Extensible code style checker currently supporting Fortran, PSyclone DSL, etc
-long_description = file:README.rst
-long_description_content_type = text/x-rst
-url = https://github.com/MetOffice/stylist
-author = Met Office
-license = BSD 3-Clause
-license_files = LICENSE
-keywords = linter fortran psyclone
-classifiers=Development Status :: 3 - Alpha
-            Intended Audience :: Developers
-            Topic :: Software Development :: Quality Assurance
-            License :: OSI Approved :: BSD License
-            Programming Language :: Python :: 3.7
-            Programming Language :: Python :: 3.8
-            Programming Language :: Python :: 3.9
-            Programming Language :: Python :: 3.10
diff --git a/source/stylist/__main__.py b/source/stylist/__main__.py
index 1e20b29..3ad0716 100755
--- a/source/stylist/__main__.py
+++ b/source/stylist/__main__.py
@@ -13,7 +13,7 @@ from os import linesep
 from pathlib import Path
 import sys
 from textwrap import indent
-from typing import List, Sequence
+from typing import List, Sequence, Union
 
 from stylist import StylistException
 from stylist.configuration import (Configuration,
@@ -25,6 +25,11 @@ from stylist.source import SourceFactory
 from stylist.style import Style
 
 
+# Paths to site-wide and per-user style files
+site_file = Path("/etc/stylist.py")
+user_file = Path.home() / ".stylist.py"
+
+
 def __parse_cli() -> argparse.Namespace:
     """
     Parse the command line for stylist arguments.
@@ -107,10 +112,30 @@ def __process(candidates: List[Path], styles: Sequence[Style]) -> List[Issue]:
     return issues
 
 
-def __configure(project_file: Path) -> Configuration:
-    configuration = load_configuration(project_file)
-    # TODO /etc/fab.ini
-    # TODO ~/.fab.ini - Path.home() / '.fab.ini'
+def __configure(project_file: Path) -> Union[Configuration, None]:
+    """
+    Load configuration styles in order of specificity
+
+    Load the global site configuration, the per-user configuration, and
+    finally the configuration option provided on the command line.
+    More specific options are allowed to override more general ones,
+    allowing a configuration to built up gradually.
+    """
+
+    candidates = [site_file, user_file, project_file]
+
+    configuration = None
+
+    for target in candidates:
+        if target is None or not target.exists():
+            continue
+
+        style = load_configuration(target)
+        if configuration is None:
+            configuration = style
+        else:
+            configuration.overload(style)
+
     return configuration
 
 
@@ -187,6 +212,11 @@ def main() -> None:
         logger.setLevel(logging.WARNING)
 
     configuration = __configure(arguments.configuration)
+    if configuration is None:
+        # No valid configuration files have been found
+        # FIXME: proper exit handling
+        raise Exception("no valid style files found")
+
     issues = perform(configuration,
                      arguments.source,
                      arguments.style,
diff --git a/source/stylist/configuration.py b/source/stylist/configuration.py
index ad025b5..84a37bd 100644
--- a/source/stylist/configuration.py
+++ b/source/stylist/configuration.py
@@ -9,6 +9,8 @@ Handles configuration parameters coming from various potential sources.
 
 Configuration may be defined by software or read from a Windows .ini file.
 """
+from __future__ import annotations
+
 from importlib.util import module_from_spec, spec_from_file_location
 from pathlib import Path
 from typing import Dict, List, Tuple, Type
@@ -37,6 +39,10 @@ class Configuration:
     def add_style(self, name: str, style: Style):
         self._styles[name] = style
 
+    def overload(self, other: Configuration) -> None:
+        self._pipes = {**self._pipes, **other._pipes}
+        self._styles = {**self._styles, **other._styles}
+
     @property
     def file_pipes(self) -> Dict[str, FilePipe]:
         return self._pipes


Test patch:
diff --git a/unit-tests/__main__test.py b/unit-tests/__main__test.py
index 1f9cff8..f715ecb 100644
--- a/unit-tests/__main__test.py
+++ b/unit-tests/__main__test.py
@@ -7,12 +7,13 @@
 """
 Tests the reporting of error conditions.
 """
-from pathlib import Path
 
-from pytest import raises
+from pathlib import Path
+from pytest import raises, fixture
 
 from stylist import StylistException
-from stylist.__main__ import perform
+from stylist.__main__ import perform, __configure
+import stylist.__main__ as maintest
 from stylist.configuration import Configuration
 from stylist.style import Style
 
@@ -40,3 +41,97 @@ def test_missing_style(tmp_path: Path):
         _ = perform(configuration, [tmp_path], ['missing'], [], verbose=False)
     assert ex.value.message \
            == 'Style "missing" is not defined by the configuration.'
+
+
+@fixture(scope="session")
+def site_config(tmp_path_factory):
+    site = tmp_path_factory.mktemp("data") / "site.py"
+    site.write_text("\n".join([
+        "from stylist.source import FilePipe, PlainText",
+        "from stylist.rule import LimitLineLength",
+        "from stylist.style import Style",
+        "txt = FilePipe(PlainText)",
+        "foo = Style(LimitLineLength(80))",
+    ]))
+
+    return site
+
+
+@fixture(scope="session")
+def user_config(tmp_path_factory):
+    user = tmp_path_factory.mktemp("data") / "user.py"
+    user.write_text("\n".join([
+        "from stylist.source import FilePipe, PlainText",
+        "from stylist.rule import TrailingWhitespace",
+        "from stylist.style import Style",
+        "txt = FilePipe(PlainText)",
+        "bar = Style(TrailingWhitespace())",
+    ]))
+
+    return user
+
+
+@fixture(scope="session")
+def project_config(tmp_path_factory):
+    project = tmp_path_factory.mktemp("data") / "project.py"
+    project.write_text("\n".join([
+        "from stylist.source import FilePipe, PlainText",
+        "from stylist.rule import LimitLineLength, TrailingWhitespace",
+        "from stylist.style import Style",
+        "txt = FilePipe(PlainText)",
+        "foo = Style(LimitLineLength(80), TrailingWhitespace)",
+    ]))
+
+    return project
+
+
+def test_no_configurations(tmp_path):
+
+    maintest.site_file = None
+    maintest.user_file = None
+
+    configuration = __configure(None)
+    assert configuration is None
+
+
+def test_site_only_configuration(site_config):
+
+    maintest.site_file = site_config
+    maintest.user_file = None
+
+    configuration = __configure(None)
+    assert configuration is not None
+    assert list(configuration.styles) == ["foo"]
+    assert len(configuration.styles["foo"].list_rules()) == 1
+
+
+def test_user_only_configuration(user_config):
+
+    maintest.site_file = None
+    maintest.user_file = user_config
+
+    configuration = __configure(None)
+    assert configuration is not None
+    assert list(configuration.styles) == ["bar"]
+    assert len(configuration.styles["bar"].list_rules()) == 1
+
+
+def test_user_and_site_configurations(site_config, user_config):
+
+    maintest.site_file = site_config
+    maintest.user_file = user_config
+
+    configuration = __configure(None)
+    assert configuration is not None
+    assert list(configuration.styles) == ["foo", "bar"]
+
+
+def test_all_configurations(site_config, user_config, project_config):
+
+    maintest.site_file = site_config
+    maintest.user_file = user_config
+
+    configuration = __configure(project_config)
+    assert configuration is not None
+    assert list(configuration.styles) == ["foo", "bar"]
+    assert len(configuration.styles["foo"].list_rules()) == 2
diff --git a/unit-tests/configuration_test.py b/unit-tests/configuration_test.py
index 2755b95..2e48196 100644
--- a/unit-tests/configuration_test.py
+++ b/unit-tests/configuration_test.py
@@ -177,3 +177,57 @@ class TestConfiguration:
         assert len(style.list_rules()) == 1
         assert isinstance(style.list_rules()[0], DummyRuleOne)
         assert cast(DummyRuleOne, style.list_rules()[0]).first.pattern == r'.*'
+
+    def test_config_add_pipes(self, tmp_path: Path):
+        first_file = tmp_path / 'first.py'
+        first_file.write_text(dedent("""
+            from stylist.source import FilePipe
+            from configuration_test import DummySource
+            foo = FilePipe(DummySource)
+            """))
+
+        second_file = tmp_path / 'second.py'
+        second_file.write_text(dedent("""
+            from stylist.source import FilePipe
+            from configuration_test import DummyProcOne, DummySource
+            foo = FilePipe(DummySource, DummyProcOne)
+            """))
+
+        configuration = load_configuration(first_file)
+        assert list(configuration.file_pipes.keys()) == ['foo']
+        style = configuration.file_pipes['foo']
+        assert style.parser == DummySource
+        assert len(style.preprocessors) == 0
+        assert configuration.styles == {}
+
+        configuration.overload(load_configuration(second_file))
+        assert list(configuration.file_pipes.keys()) == ['foo']
+        style = configuration.file_pipes['foo']
+        assert style.parser == DummySource
+        assert len(style.preprocessors) == 1
+        assert configuration.styles == {}
+
+    def test_config_add_styles(self, tmp_path: Path):
+        first_file = tmp_path / 'first.py'
+        first_file.write_text(dedent("""
+            from stylist.style import Style
+            from configuration_test import DummyRuleZero
+            foo = Style(DummyRuleZero())
+            """))
+
+        second_file = tmp_path / 'second.py'
+        second_file.write_text(dedent("""
+            from stylist.style import Style
+            from configuration_test import DummyRuleOne
+            foo = Style(DummyRuleOne(1))
+            """))
+
+        configuration = load_configuration(first_file)
+        assert list(configuration.styles.keys()) == ['foo']
+        style = configuration.styles['foo']
+        assert isinstance(style.list_rules()[0], DummyRuleZero)
+
+        configuration.overload(load_configuration(second_file))
+        assert list(configuration.styles.keys()) == ['foo']
+        style = configuration.styles['foo']
+        assert isinstance(style.list_rules()[0], DummyRuleOne)