Initial Commit
This commit is contained in:
commit
eb0b6ec0fe
66
.github/workflows/build.yaml
vendored
Normal file
66
.github/workflows/build.yaml
vendored
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
*.egg-info
|
||||
*.py[co]
|
||||
/.coverage
|
||||
/.tox
|
||||
/dist
|
||||
.vscode/
|
||||
build/
|
47
.pre-commit-config.yaml
Normal file
47
.pre-commit-config.yaml
Normal 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
19
LICENSE
Normal 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
66
README.md
Normal 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
5
requirements-dev.txt
Normal file
@ -0,0 +1,5 @@
|
||||
build
|
||||
covdefaults
|
||||
coverage
|
||||
pytest
|
||||
tox
|
472
settngs.py
Normal file
472
settngs.py
Normal 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
47
setup.cfg
Normal 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
4
setup.py
Normal file
@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from setuptools import setup
|
||||
setup()
|
0
testing/__init__.py
Normal file
0
testing/__init__.py
Normal file
245
testing/settngs.py
Normal file
245
testing/settngs.py
Normal 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
0
tests/__init__.py
Normal file
253
tests/settngs_test.py
Normal file
253
tests/settngs_test.py
Normal 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
17
tox.ini
Normal 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
117
workflows/build.yaml
Normal 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
93
workflows/package.yaml
Normal 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
|
Loading…
Reference in New Issue
Block a user