From eb0b6ec0fe70aa10316c175fba23b6e870362c23 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 14 Dec 2022 19:42:24 -0800 Subject: [PATCH] Initial Commit --- .github/workflows/build.yaml | 66 +++++ .gitignore | 7 + .pre-commit-config.yaml | 47 ++++ LICENSE | 19 ++ README.md | 66 +++++ requirements-dev.txt | 5 + settngs.py | 472 +++++++++++++++++++++++++++++++++++ setup.cfg | 47 ++++ setup.py | 4 + testing/__init__.py | 0 testing/settngs.py | 245 ++++++++++++++++++ tests/__init__.py | 0 tests/settngs_test.py | 253 +++++++++++++++++++ tox.ini | 17 ++ workflows/build.yaml | 117 +++++++++ workflows/package.yaml | 93 +++++++ 16 files changed, 1458 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 requirements-dev.txt create mode 100644 settngs.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 testing/__init__.py create mode 100644 testing/settngs.py create mode 100644 tests/__init__.py create mode 100644 tests/settngs_test.py create mode 100644 tox.ini create mode 100644 workflows/build.yaml create mode 100644 workflows/package.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..32a1dfc --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,66 @@ +name: CI + +on: + pull_request: + push: + branches: + - '**' + tags: + - "[0-9]+.[0-9]+.[0-9]+*" + +jobs: + build-and-publish: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python_version: ['3.8', '3.9','3.10','3.11'] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python_version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python_version }} + + + - name: Install build dependencies + run: | + python -m pip install --upgrade --upgrade-strategy eager -r requirements-dev.txt + + - name: Build and install wheel + run: | + python -m build + python -m pip install dist/*.whl + + - name: tox + run: | + tox run -e "py${python_version#py}" + shell: bash + env: + python_version: ${{ matrix.python_version }} + + - name: "Publish distribution 📦 to PyPI" + if: startsWith(github.ref, 'refs/tags/') && matrix.python_version == '3.11' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Get release name + if: startsWith(github.ref, 'refs/tags/') && matrix.python_version == '3.11' + shell: bash + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* # github is dumb + echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') && matrix.python_version == '3.11' + with: + name: "${{ env.release_name }}" + draft: false + files: | + dist/*.whl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3fd047 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.egg-info +*.py[co] +/.coverage +/.tox +/dist +.vscode/ +build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c86643f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: debug-statements + - id: double-quote-string-fixer + - id: name-tests-test + - id: requirements-txt-fixer +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v2.2.0 + hooks: + - id: setup-cfg-fmt +- repo: https://github.com/asottile/reorder_python_imports + rev: v3.9.0 + hooks: + - id: reorder-python-imports + args: [--py38-plus, --add-import, 'from __future__ import annotations'] +- repo: https://github.com/asottile/add-trailing-comma + rev: v2.4.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] +- repo: https://github.com/asottile/dead + rev: v1.5.0 + hooks: + - id: dead +- repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: [--py38-plus] +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v2.0.0 + hooks: + - id: autopep8 +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a071fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc4c36f --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +[![CI](https://github.com/lordwelch/settngs/actions/workflows/build.yaml/badge.svg?branch=main&event=push)](https://github.com/lordwelch/settngs/actions/workflows/build.yaml) +[![GitHub release (latest by date)](https://img.shields.io/github/downloads/lordwelch/settngs/latest/total)](https://github.com/lordwelch/settngs/releases/latest) +[![PyPI](https://img.shields.io/pypi/v/settngs)](https://pypi.org/project/settngs/) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/settngs)](https://pypistats.org/packages/settngs) +[![PyPI - License](https://img.shields.io/pypi/l/settngs)](https://opensource.org/licenses/MIT) + +# Settngs + +This library is an attempt to merge reading flags/options from the commandline (argparse) and settings from a file (json). + +It is a modified argparse inspired by how [flake8] loads their settings. Note that this does not attempt to be a drop-in replacement for argparse. + +Install with pip +```console +pip install settngs +``` + + +A trivial example is included at the bottom of settngs.py with the output below. For a more complete example see [ComicTagger]. +```console +$ python -m settngs +Hello world +$ python -m settngs --hello lordwelch +Hello lordwelch +$ python -m settngs --hello lordwelch -s +Hello lordwelch +Successfully saved settngs to settngs.json +$ python -m settngs +Hello lordwelch +$ python -m settngs -v +Hello lordwelch +merged_namespace.example_verbose=True +$ python -m settngs -v -s +Hello lordwelch +Successfully saved settngs to settngs.json +merged_namespace.example_verbose=True +$ python -m settngs +Hello lordwelch +merged_namespace.example_verbose=True +$ python -m settngs --no-verbose +Hello lordwelch +$ python -m settngs --no-verbose -s +Hello lordwelch +Successfully saved settngs to settngs.json +$ python -m settngs --hello world --no-verbose -s +Hello world +Successfully saved settngs to settngs.json +$ python -m settngs +Hello world +``` + +settngs.json at the end: +```json +{ + "example": { + "hello": "world", + "verbose": false + } +} +``` + +## What happened to the 'i'? +PyPi wouldn't let me use 'settings' + +[flake8]: https://github.com/PyCQA/flake8 +[ComicTagger]: https://github.com/comictagger/comictagger diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..997bde9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +build +covdefaults +coverage +pytest +tox diff --git a/settngs.py b/settngs.py new file mode 100644 index 0000000..44ed247 --- /dev/null +++ b/settngs.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import argparse +import json +import logging +import pathlib +from collections import defaultdict +from collections.abc import Sequence +from typing import Any +from typing import Callable +from typing import Dict +from typing import NamedTuple +from typing import NoReturn +from typing import TYPE_CHECKING +from typing import Union + +logger = logging.getLogger(__name__) + + +class Setting: + def __init__( + self, + # From argparse + *names: str, + action: type[argparse.Action] | None = None, + nargs: str | int | None = None, + const: str | None = None, + default: str | None = None, + type: Callable[..., Any] | None = None, # noqa: A002 + choices: Sequence[Any] | None = None, + required: bool | None = None, + help: str | None = None, # noqa: A002 + metavar: str | None = None, + dest: str | None = None, + # ComicTagger + cmdline: bool = True, + file: bool = True, + group: str = '', + exclusive: bool = False, + ): + if not names: + raise ValueError('names must be specified') + # We prefix the destination name used by argparse so that there are no conflicts + # Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice + self.internal_name, dest, flag = self.get_dest(group, names, dest) + args: Sequence[str] = names + + # We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG' + if not metavar and action not in ('store_true', 'store_false', 'count'): + metavar = dest.upper() + + # If we are not a flag, no '--' or '-' in front + # we prefix the first name with the group as argparse sets dest to args[0] + # I believe internal name may be able to be used here + if not flag: + args = tuple((f'{group}_{names[0]}'.lstrip('_'), *names[1:])) + + self.action = action + self.nargs = nargs + self.const = const + self.default = default + self.type = type + self.choices = choices + self.required = required + self.help = help + self.metavar = metavar + self.dest = dest + self.cmdline = cmdline + self.file = file + self.argparse_args = args + self.group = group + self.exclusive = exclusive + + self.argparse_kwargs = { + 'action': action, + 'nargs': nargs, + 'const': const, + 'default': default, + 'type': type, + 'choices': choices, + 'required': required, + 'help': help, + 'metavar': metavar, + 'dest': self.internal_name if flag else None, + } + + def __str__(self) -> str: + return f'Setting({self.argparse_args}, type={self.type}, file={self.file}, cmdline={self.cmdline}, kwargs={self.argparse_kwargs})' + + def __repr__(self) -> str: + return self.__str__() + + def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]: + dest_name = None + flag = False + + for n in names: + if n.startswith('--'): + flag = True + dest_name = n.lstrip('-').replace('-', '_') + break + if n.startswith('-'): + flag = True + + if dest_name is None: + dest_name = names[0] + if dest: + dest_name = dest + + internal_name = f'{prefix}_{dest_name}'.lstrip('_') + return internal_name, dest_name, flag + + def filter_argparse_kwargs(self) -> dict[str, Any]: + return {k: v for k, v in self.argparse_kwargs.items() if v is not None} + + def to_argparse(self) -> tuple[Sequence[str], dict[str, Any]]: + return self.argparse_args, self.filter_argparse_kwargs() + + +Values = Dict[str, Dict[str, Any]] +Definitions = Dict[str, Dict[str, Setting]] + + +class Config(NamedTuple): + values: Values + definitions: Definitions + + +if TYPE_CHECKING: + ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] + + +def get_option(options: Values | argparse.Namespace, setting: Setting) -> tuple[Any, bool]: + """ + Helper function to retrieve the value for a setting and if the value is the default value + + Args: + options: Dictionary or namespace of options + setting: The setting object describing the value to retrieve + """ + if isinstance(options, dict): + value = options.get(setting.group, {}).get(setting.dest, setting.default) + else: + value = getattr(options, setting.internal_name, setting.default) + return value, value == setting.default + + +def normalize_config( + raw_options: Values | argparse.Namespace, + definitions: Definitions, + file: bool = False, + cmdline: bool = False, + defaults: bool = True, + raw_options_2: Values | argparse.Namespace | None = None, +) -> Values: + """ + Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions` + and values taken from `raw_options` and `raw_options_2' if defined. + Values are assigned so if the value is a dictionary mutating it will mutate the original. + + Args: + raw_options: The dict or Namespace to normalize options from + definitions: The definition of the options + file: Include file options + cmdline: Include cmdline options + defaults: Include default values in the returned dict + raw_options_2: If set, merges non-default values into the returned dict + """ + options: Values = {} + for group_name, group in definitions.items(): + group_options = {} + for setting_name, setting in group.items(): + if (setting.cmdline and cmdline) or (setting.file and file): + # Ensures the option exists with the default if not already set + value, default = get_option(raw_options, setting) + if not default or default and defaults: + group_options[setting_name] = value + + # will override with option from raw_options_2 if it is not the default + if raw_options_2 is not None: + value, default = get_option(raw_options_2, setting) + if not default: + group_options[setting_name] = value + options[group_name] = group_options + # options["definitions"] = definitions + return options + + +def parse_file(definitions: Definitions, filename: pathlib.Path) -> tuple[Values, bool]: + """ + Helper function to read options from a json dictionary from a file + Args: + filename: A pathlib.Path object to read a json dictionary from + """ + options: Values = {} + success = True + if filename.exists(): + try: + with filename.open() as file: + opts = json.load(file) + if isinstance(opts, dict): + options = opts + except Exception: + logger.exception('Failed to load config file: %s', filename) + success = False + else: + logger.info('No config file found') + success = False + + return (normalize_config(options, definitions, file=True), success) + + +def clean_config( + options: Values | argparse.Namespace, definitions: Definitions, file: bool = False, cmdline: bool = False, +) -> Values: + """ + Normalizes options and then cleans up empty groups and removes 'definitions' + Args: + options: + file: + cmdline: + + Returns: + + """ + + clean_options = normalize_config(options, definitions, file=file, cmdline=cmdline) + for group in list(clean_options.keys()): + if not clean_options[group]: + del clean_options[group] + return clean_options + + +def defaults(definitions: Definitions) -> Values: + return normalize_config({}, definitions, file=True, cmdline=True) + + +def get_namespace(options: Values, definitions: Definitions, defaults: bool = True) -> argparse.Namespace: + """ + Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" + `options` should already be normalized. + Throws an exception if the internal_name is duplicated + + Args: + options: Normalized options to turn into a Namespace + defaults: Include default values in the returned dict + """ + options = normalize_config(options, definitions, file=True, cmdline=True) + namespace = argparse.Namespace() + for group_name, group in definitions.items(): + for setting_name, setting in group.items(): + if hasattr(namespace, setting.internal_name): + raise Exception(f'Duplicate internal name: {setting.internal_name}') + value, default = get_option(options, setting) + + if not default or default and defaults: + setattr(namespace, setting.internal_name, value) + return namespace + + +def save_file( + options: Values | argparse.Namespace, definitions: Definitions, filename: pathlib.Path, +) -> bool: + """ + Helper function to save options from a json dictionary to a file + Args: + options: The options to save to a json dictionary + filename: A pathlib.Path object to save the json dictionary to + """ + file_options = clean_config(options, definitions, file=True) + if not filename.exists(): + filename.parent.mkdir(exist_ok=True, parents=True) + filename.touch() + + try: + json_str = json.dumps(file_options, indent=2) + filename.write_text(json_str, encoding='utf-8') + except Exception: + logger.exception('Failed to save config file: %s', filename) + return False + return True + + +def create_argparser(definitions: Definitions, description: str, epilog: str) -> argparse.ArgumentParser: + """Creates an :class:`argparse.ArgumentParser` from all cmdline settings""" + groups: dict[str, ArgParser] = {} + argparser = argparse.ArgumentParser( + description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter, + ) + for group_name, group in definitions.items(): + for setting_name, setting in group.items(): + if setting.cmdline: + argparse_args, argparse_kwargs = setting.to_argparse() + current_group: ArgParser = argparser + if setting.group: + if setting.group not in groups: + if setting.exclusive: + groups[setting.group] = argparser.add_argument_group( + setting.group, + ).add_mutually_exclusive_group() + else: + groups[setting.group] = argparser.add_argument_group(setting.group) + + # hard coded exception for files + if not (setting.group == 'runtime' and setting.nargs == '*'): + current_group = groups[setting.group] + current_group.add_argument(*argparse_args, **argparse_kwargs) + return argparser + + +def parse_cmdline( + definitions: Definitions, + description: str, + epilog: str, + args: list[str] | None = None, + namespace: argparse.Namespace | None = None, +) -> Values: + """ + Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`. + `args` and `namespace` are passed to `argparse.ArgumentParser.parse_args` + + Args: + args: Passed to argparse.ArgumentParser.parse + namespace: Passed to argparse.ArgumentParser.parse + """ + argparser = create_argparser(definitions, description, epilog) + ns = argparser.parse_args(args, namespace=namespace) + + return normalize_config(ns, definitions=definitions, cmdline=True, file=True) + + +def parse_config( + definitions: Definitions, + description: str, + epilog: str, + config_path: pathlib.Path, + args: list[str] | None = None, +) -> tuple[Values, bool]: + file_options, success = parse_file(definitions, config_path) + cmdline_options = parse_cmdline( + definitions, description, epilog, args, get_namespace(file_options, definitions, defaults=False), + ) + + final_options = normalize_config(cmdline_options, definitions=definitions, file=True, cmdline=True) + return (final_options, success) + + +class Manager: + """docstring for Manager""" + + def __init__(self, description: str = '', epilog: str = '', definitions: Definitions | None = None): + # This one is never used, it just makes MyPy happy + self.argparser = argparse.ArgumentParser(description=description, epilog=epilog) + self.description = description + self.epilog = epilog + + self.definitions: Definitions = defaultdict(lambda: dict(), definitions or {}) + + self.exclusive_group = False + self.current_group_name = '' + + def create_argparser(self) -> None: + self.argparser = create_argparser(self.definitions, self.description, self.epilog) + + def add_setting(self, *args: Any, **kwargs: Any) -> None: + """Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set""" + setting = Setting(*args, group=self.current_group_name, exclusive=self.exclusive_group, **kwargs) + self.definitions[self.current_group_name][setting.dest] = setting + + def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: + """ + The primary way to add define options on this class + + Args: + name: The name of the group to define + add_settings: A function that registers individual options using :meth:`add_setting` + exclusive_group: If this group is an argparse exclusive group + """ + self.current_group_name = name + self.exclusive_group = exclusive_group + add_settings(self) + self.current_group_name = '' + self.exclusive_group = False + + def exit(self, *args: Any, **kwargs: Any) -> NoReturn: + """See :class:`~argparse.ArgumentParser`""" + self.argparser.exit(*args, **kwargs) + raise SystemExit(99) + + def defaults(self) -> Values: + return defaults(self.definitions) + + def clean_config( + self, options: Values | argparse.Namespace, file: bool = False, cmdline: bool = False, + ) -> Values: + return clean_config(options=options, definitions=self.definitions, file=file, cmdline=cmdline) + + def normalize_config( + self, + raw_options: Values | argparse.Namespace, + file: bool = False, + cmdline: bool = False, + defaults: bool = True, + raw_options_2: Values | argparse.Namespace | None = None, + ) -> Config: + return Config( + normalize_config( + raw_options=raw_options, + definitions=self.definitions, + file=file, + cmdline=cmdline, + defaults=defaults, + raw_options_2=raw_options_2, + ), + self.definitions, + ) + + def get_namespace(self, options: Values, defaults: bool = True) -> argparse.Namespace: + return get_namespace(options=options, definitions=self.definitions, defaults=defaults) + + def parse_file(self, filename: pathlib.Path) -> tuple[Values, bool]: + return parse_file(filename=filename, definitions=self.definitions) + + def save_file(self, options: Values | argparse.Namespace, filename: pathlib.Path) -> bool: + return save_file(options=options, definitions=self.definitions, filename=filename) + + def parse_cmdline(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> Values: + return parse_cmdline(self.definitions, self.description, self.epilog, args, namespace) + + def parse_config(self, config_path: pathlib.Path, args: list[str] | None = None) -> tuple[Config, bool]: + values, success = parse_config(self.definitions, self.description, self.epilog, config_path, args) + return (Config(values, self.definitions), success) + + +def example(manager: Manager) -> None: + manager.add_setting( + '--hello', + default='world', + ) + manager.add_setting( + '--save', '-s', + default=False, + action='store_true', + file=False, + ) + manager.add_setting( + '--verbose', '-v', + default=False, + action=argparse.BooleanOptionalAction, + ) + + +if __name__ == '__main__': + settings_path = pathlib.Path('./settings.json') + manager = Manager(description='This is an example', epilog='goodbye!') + + manager.add_group('example', example) + + file_config, success = manager.parse_file(settings_path) + file_namespace = manager.get_namespace(file_config) + + merged_config = manager.parse_cmdline(namespace=file_namespace) + merged_namespace = manager.get_namespace(merged_config) + + print(f'Hello {merged_config["example"]["hello"]}') # noqa: T201 + if merged_namespace.example_save: + if manager.save_file(merged_config, settings_path): + print(f'Successfully saved settings to {settings_path}') # noqa: T201 + else: + print(f'Failed saving settings to a {settings_path}') # noqa: T201 + if merged_namespace.example_verbose: + print(f'{merged_namespace.example_verbose=}') # noqa: T201 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..db728ef --- /dev/null +++ b/setup.cfg @@ -0,0 +1,47 @@ +[metadata] +name = settngs +version = 0.2.0 +description = A library for managing settings +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/lordwelch/settngs +author = Timmy Welch +author_email = timmy@narnian.us +license = MIT +license_files = LICENSE +classifiers = + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + +[options] +py_modules = settngs +python_requires = >=3.8 + +[options.packages.find] +exclude = + tests* + testing* + +[coverage:run] +plugins = covdefaults + +[coverage:report] +fail_under = 95 + +[mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +[mypy-testing.*] +disallow_untyped_defs = false + +[mypy-tests.*] +disallow_untyped_defs = false diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3d93aef --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from setuptools import setup +setup() diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/settngs.py b/testing/settngs.py new file mode 100644 index 0000000..205406d --- /dev/null +++ b/testing/settngs.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import pytest +success = [ + ( + ( + ('--test',), + dict( + group='tst', + dest='testing', + ), + ), # Equivalent to Setting("--test", group="tst") + { + 'action': None, + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'testing', # dest is calculated by Setting and is not used by argparse + 'exclusive': False, + 'file': True, + 'group': 'tst', + 'help': None, + 'internal_name': 'tst_testing', # Should almost always be "{group}_{dest}" + 'metavar': 'TESTING', # Set manually so argparse doesn't use TST_TEST + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('--test',), # *args actually sent to argparse + 'argparse_kwargs': { + 'action': None, + 'choices': None, + 'const': None, + 'default': None, + 'dest': 'tst_testing', + 'help': None, + 'metavar': 'TESTING', + 'nargs': None, + 'required': None, + 'type': None, + }, # Non-None **kwargs sent to argparse + }, + ), + ( + ( + ('--test',), + dict( + group='tst', + ), + ), # Equivalent to Setting("--test", group="tst") + { + 'action': None, + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'test', # dest is calculated by Setting and is not used by argparse + 'exclusive': False, + 'file': True, + 'group': 'tst', + 'help': None, + 'internal_name': 'tst_test', # Should almost always be "{group}_{dest}" + 'metavar': 'TEST', # Set manually so argparse doesn't use TST_TEST + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('--test',), # *args actually sent to argparse + 'argparse_kwargs': { + 'action': None, + 'choices': None, + 'const': None, + 'default': None, + 'dest': 'tst_test', + 'help': None, + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + }, # Non-None **kwargs sent to argparse + }, + ), + ( + ( + ('--test',), + dict( + action='store_true', + group='tst', + ), + ), # Equivalent to Setting("--test", group="tst", action="store_true") + { + 'action': 'store_true', + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'test', # dest is calculated by Setting and is not used by argparse + 'exclusive': False, + 'file': True, + 'group': 'tst', + 'help': None, + 'internal_name': 'tst_test', # Should almost always be "{group}_{dest}" + 'metavar': None, # store_true does not get a metavar + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('--test',), # *args actually sent to argparse + 'argparse_kwargs': { + 'action': 'store_true', + 'choices': None, + 'const': None, + 'default': None, + 'dest': 'tst_test', + 'help': None, + 'metavar': None, + 'nargs': None, + 'required': None, + 'type': None, + }, # Non-None **kwargs sent to argparse + }, + ), + ( + ( + ('-t', '--test'), + dict( + group='tst', + ), + ), # Equivalent to Setting("-t", "--test", group="tst") + { + 'action': None, + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'test', + 'exclusive': False, + 'file': True, + 'group': 'tst', + 'help': None, + 'internal_name': 'tst_test', + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('-t', '--test'), # Only difference with above is here + 'argparse_kwargs': { + 'action': None, + 'choices': None, + 'const': None, + 'default': None, + 'dest': 'tst_test', + 'help': None, + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + }, + }, + ), + ( + ( + ('test',), + dict( + group='tst', + ), + ), # Equivalent to Setting("test", group="tst") + { + 'action': None, + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'test', + 'exclusive': False, + 'file': True, + 'group': 'tst', + 'help': None, + 'internal_name': 'tst_test', + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('tst_test',), + 'argparse_kwargs': { + 'action': None, + 'choices': None, + 'const': None, + 'default': None, + 'dest': None, # Only difference with #1 is here, argparse sets dest based on the *args passed to it + 'help': None, + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + }, + }, + ), + ( + ( + ('--test',), + dict(), + ), # Equivalent to Setting("test") + { + 'action': None, + 'choices': None, + 'cmdline': True, + 'const': None, + 'default': None, + 'dest': 'test', + 'exclusive': False, + 'file': True, + 'group': '', + 'help': None, + 'internal_name': 'test', # No group, leading _ is stripped + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + 'argparse_args': ('--test',), + 'argparse_kwargs': { + 'action': None, + 'choices': None, + 'const': None, + 'default': None, + 'dest': 'test', + 'help': None, + 'metavar': 'TEST', + 'nargs': None, + 'required': None, + 'type': None, + }, + }, + ), +] + +failure = [ + ( + ( + (), + dict( + group='tst', + ), + ), # Equivalent to Setting(group="tst") + pytest.raises(ValueError), + ), +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settngs_test.py b/tests/settngs_test.py new file mode 100644 index 0000000..95d6f78 --- /dev/null +++ b/tests/settngs_test.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import argparse +import json + +import pytest + +import settngs +from testing.settngs import failure +from testing.settngs import success + + +@pytest.fixture +def settngs_manager(): + manager = settngs.Manager() + yield manager + + +def test_settngs_manager(): + manager = settngs.Manager() + defaults = manager.defaults() + assert manager is not None and defaults is not None + + +@pytest.mark.parametrize('arguments, expected', success) +def test_setting_success(arguments, expected): + assert vars(settngs.Setting(*arguments[0], **arguments[1])) == expected + + +@pytest.mark.parametrize('arguments, exception', failure) +def test_setting_failure(arguments, exception): + with exception: + settngs.Setting(*arguments[0], **arguments[1]) + + +def test_add_setting(settngs_manager): + assert settngs_manager.add_setting('--test') is None + + +def test_get_defaults(settngs_manager): + settngs_manager.add_setting('--test', default='hello') + defaults = settngs_manager.defaults() + assert defaults['']['test'] == 'hello' + + +def test_get_namespace(settngs_manager): + settngs_manager.add_setting('--test', default='hello') + defaults = settngs_manager.get_namespace(settngs_manager.defaults()) + assert defaults.test == 'hello' + + +def test_get_defaults_group(settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) + defaults = settngs_manager.defaults() + assert defaults['tst']['test'] == 'hello' + + +def test_get_namespace_group(settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) + defaults = settngs_manager.get_namespace(settngs_manager.defaults()) + assert defaults.tst_test == 'hello' + + +def test_cmdline_only(settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False)) + settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', cmdline=False)) + + file_normalized, _ = settngs_manager.normalize_config({}, file=True) + cmdline_normalized, _ = settngs_manager.normalize_config({}, cmdline=True) + + assert 'test' in cmdline_normalized['tst'] + assert 'test2' not in cmdline_normalized['tst2'] + + assert 'test' not in file_normalized['tst'] + assert 'test2' in file_normalized['tst2'] + + +def test_normalize(settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) + + defaults = settngs_manager.defaults() + defaults['test'] = 'fail' # Not defined in settngs_manager + + defaults_namespace = settngs_manager.get_namespace(defaults) + defaults_namespace.test = 'fail' + + normalized, _ = settngs_manager.normalize_config(defaults, file=True) + normalized_namespace = settngs_manager.get_namespace(settngs_manager.normalize_config(defaults, file=True)[0]) + + assert 'test' not in normalized + assert 'tst' in normalized + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'hello' + + assert not hasattr(normalized_namespace, 'test') + assert hasattr(normalized_namespace, 'tst_test') + assert normalized_namespace.tst_test == 'hello' + + +def test_clean_config(settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + settngs_manager.add_group('tst2', lambda parser: parser.add_setting('--test2', default='hello', file=False)) + normalized = settngs_manager.defaults() + normalized['tst']['test'] = 'success' + normalized['fail'] = 'fail' + + cleaned = settngs_manager.clean_config(normalized, file=True) + + assert 'fail' not in cleaned + assert 'tst2' not in cleaned + assert cleaned['tst']['test'] == 'success' + + +def test_parse_cmdline(settngs_manager, tmp_path): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=True)) + + normalized = settngs_manager.parse_cmdline(['--test', 'success']) + + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_parse_file(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text(json.dumps({'tst': {'test': 'success'}})) + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + + normalized, success = settngs_manager.parse_file(settngs_file) + + assert success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_parse_non_existent_file(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + + normalized, success = settngs_manager.parse_file(settngs_file) + + assert not success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'hello' + + +def test_parse_corrupt_file(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text('{') + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + + normalized, success = settngs_manager.parse_file(settngs_file) + + assert not success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'hello' + + +def test_save_file(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + normalized = settngs_manager.defaults() + normalized['tst']['test'] = 'success' + + success = settngs_manager.save_file(normalized, settngs_file) + normalized, success_r = settngs_manager.parse_file(settngs_file) + + assert success and success_r + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_save_file_not_seriazable(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + normalized = settngs_manager.defaults() + normalized['tst']['test'] = {'fail'} # Sets are not serializabl + + success = settngs_manager.save_file(normalized, settngs_file) + normalized, success_r = settngs_manager.parse_file(settngs_file) + + assert not (success and success_r) + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'hello' + + +@pytest.mark.parametrize( + 'raw, raw2, expected', + [ + ({'tst': {'test': 'fail'}}, argparse.Namespace(tst_test='success'), 'success'), + # hello is default so is not used in raw_options_2 + ({'tst': {'test': 'success'}}, argparse.Namespace(tst_test='hello'), 'success'), + (argparse.Namespace(tst_test='fail'), {'tst': {'test': 'success'}}, 'success'), + (argparse.Namespace(tst_test='success'), {'tst': {'test': 'hello'}}, 'success'), + ], +) +def test_normalize_merge(raw, raw2, expected, settngs_manager): + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) + + normalized, _ = settngs_manager.normalize_config(raw, file=True, raw_options_2=raw2) + + assert normalized['tst']['test'] == expected + + +def test_cli_set(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text(json.dumps({})) + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', file=False)) + + config, success = settngs_manager.parse_config(settngs_file, ['--test', 'success']) + normalized = config[0] + + assert success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_file_set(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text(json.dumps({'tst': {'test': 'success'}})) + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello', cmdline=False)) + + config, success = settngs_manager.parse_config(settngs_file, []) + normalized = config[0] + + assert success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_cli_override_file(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text(json.dumps({'tst': {'test': 'fail'}})) + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='hello')) + + config, success = settngs_manager.parse_config(settngs_file, ['--test', 'success']) + normalized = config[0] + + assert success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' + + +def test_cli_explicit_default(settngs_manager, tmp_path): + settngs_file = tmp_path / 'settngs.json' + settngs_file.write_text(json.dumps({'tst': {'test': 'fail'}})) + settngs_manager.add_group('tst', lambda parser: parser.add_setting('--test', default='success')) + + config, success = settngs_manager.parse_config(settngs_file, ['--test', 'success']) + normalized = config[0] + + assert success + assert 'test' in normalized['tst'] + assert normalized['tst']['test'] == 'success' diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8f5bad3 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +envlist = py3.8,py3.9,py3.10,py3.11,pypy3 + +[testenv] +deps = -rrequirements-dev.txt +commands = + coverage erase + coverage run -m pytest {posargs:tests} + coverage report + +[pep8] +ignore = E265,E501 +max_line_length = 120 + +[flake8] +extend-ignore = E501, A003 +max_line_length = 120 diff --git a/workflows/build.yaml b/workflows/build.yaml new file mode 100644 index 0000000..337661e --- /dev/null +++ b/workflows/build.yaml @@ -0,0 +1,117 @@ +name: CI + +env: + PIP: pip + PYTHON: python +on: + pull_request: + push: + branches: + - '**' + tags-ignore: + - '**' +jobs: + lint: + permissions: + checks: write + contents: read + pull-requests: write + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.9] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - uses: syphar/restore-virtualenv@v1.2 + id: cache-virtualenv + + - uses: syphar/restore-pip-download-cache@v1 + if: steps.cache-virtualenv.outputs.cache-hit != 'true' + + - name: Install build dependencies + run: | + python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt + + - uses: reviewdog/action-setup@v1 + with: + reviewdog_version: nightly + - run: flake8 | reviewdog -f=flake8 -reporter=github-pr-review -tee -level=error -fail-on-error + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.9] + os: [ubuntu-latest, macos-10.15, windows-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - uses: syphar/restore-virtualenv@v1.2 + id: cache-virtualenv + + - uses: syphar/restore-pip-download-cache@v1 + if: steps.cache-virtualenv.outputs.cache-hit != 'true' + + - name: Install build dependencies + run: | + python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt + + - name: Install Windows build dependencies + run: | + choco install -y zip + if: runner.os == 'Windows' + - name: Install macos dependencies + run: | + brew install icu4c pkg-config + export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig"; + export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH" + python -m pip install --no-binary=pyicu pyicu + if: runner.os == 'macOS' + - name: Install linux dependencies + run: | + sudo apt-get install pkg-config libicu-dev + export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig"; + export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH" + python -m pip install --no-binary=pyicu pyicu + if: runner.os == 'Linux' + + - name: Build and install PyPi packages + run: | + make clean pydist + python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]" + + - name: build + run: | + make dist + + - name: Archive production artifacts + uses: actions/upload-artifact@v2 + if: runner.os != 'Linux' # linux binary currently has a segfault when running on latest fedora + with: + name: "${{ format('ComicTagger-{0}', runner.os) }}" + path: | + dist/*.zip + + - name: PyTest + run: | + python -m pytest diff --git a/workflows/package.yaml b/workflows/package.yaml new file mode 100644 index 0000000..eb37ecd --- /dev/null +++ b/workflows/package.yaml @@ -0,0 +1,93 @@ +name: Package + +env: + PIP: pip + PYTHON: python +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+*" +jobs: + package: + permissions: + contents: write + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.9] + os: [ubuntu-latest, macos-10.15, windows-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - uses: syphar/restore-virtualenv@v1.2 + id: cache-virtualenv + + - uses: syphar/restore-pip-download-cache@v1 + if: steps.cache-virtualenv.outputs.cache-hit != 'true' + + - name: Install build dependencies + run: | + python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt + + - name: Install Windows build dependencies + run: | + choco install -y zip + if: runner.os == 'Windows' + - name: Install macos dependencies + run: | + brew install icu4c pkg-config + export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig"; + export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH" + python -m pip install --no-binary=pyicu pyicu + if: runner.os == 'macOS' + - name: Install linux dependencies + run: | + sudo apt-get install pkg-config libicu-dev + export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig"; + export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH" + python -m pip install --no-binary=pyicu pyicu + if: runner.os == 'Linux' + + - name: Build, Install and Test PyPi packages + run: | + make clean pydist + python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]" + python -m flake8 + python -m pytest + + - name: "Publish distribution 📦 to PyPI" + if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Linux' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: dist + + - name: Build PyInstaller package + run: | + make dist + + - name: Get release name + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* # github is dumb + echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: "${{ env.release_name }}" + prerelease: "${{ contains(github.ref, '-') }}" # alpha-releases should be 1.3.0-alpha.x full releases should be 1.3.0 + draft: false + files: | + dist/!(*Linux).zip + dist/*${{ fromJSON('["never", ""]')[runner.os == 'Linux'] }}.whl