Initial Commit

This commit is contained in:
Timmy Welch 2022-12-14 19:42:24 -08:00
commit eb0b6ec0fe
No known key found for this signature in database
16 changed files with 1458 additions and 0 deletions

66
.github/workflows/build.yaml vendored Normal file
View File

@ -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

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*.egg-info
*.py[co]
/.coverage
/.tox
/dist
.vscode/
build/

47
.pre-commit-config.yaml Normal file
View File

@ -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

19
LICENSE Normal file
View File

@ -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.

66
README.md Normal file
View File

@ -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

5
requirements-dev.txt Normal file
View File

@ -0,0 +1,5 @@
build
covdefaults
coverage
pytest
tox

472
settngs.py Normal file
View File

@ -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

47
setup.cfg Normal file
View File

@ -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

4
setup.py Normal file
View File

@ -0,0 +1,4 @@
from __future__ import annotations
from setuptools import setup
setup()

0
testing/__init__.py Normal file
View File

245
testing/settngs.py Normal file
View File

@ -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),
),
]

0
tests/__init__.py Normal file
View File

253
tests/settngs_test.py Normal file
View File

@ -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'

17
tox.ini Normal file
View File

@ -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

117
workflows/build.yaml Normal file
View File

@ -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

93
workflows/package.yaml Normal file
View File

@ -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