Skip to content

Commit 2bb391b

Browse files
committed
Add module to support user-configurable settings
1 parent 464b29e commit 2bb391b

4 files changed

Lines changed: 103 additions & 0 deletions

File tree

{{ cookiecutter.pypi_package_name }}/doc/sphinx/conf.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def skip_module(app, what, name, obj, skip, options):
5050
{% if cookiecutter.include_executable == "y" -%}
5151
'{{ cookiecutter.python_package_name }}.__main__',
5252
'{{ cookiecutter.python_package_name }}.cli',
53+
{% endif -%}
54+
'{{ cookiecutter.python_package_name }}.config',
55+
{% if cookiecutter.include_executable == "y" -%}
5356
'{{ cookiecutter.python_package_name }}.fire_workarounds',
5457
{% endif -%}
5558
'{{ cookiecutter.python_package_name }}.version',
@@ -68,6 +71,9 @@ def setup(sphinx):
6871
{% if cookiecutter.include_executable == "y" -%}
6972
'**/{{ cookiecutter.python_package_name }}/__main__/**',
7073
'**/{{ cookiecutter.python_package_name }}/cli/**',
74+
{% endif -%}
75+
'**/{{ cookiecutter.python_package_name }}/config/**',
76+
{% if cookiecutter.include_executable == "y" -%}
7177
'**/{{ cookiecutter.python_package_name }}/fire_workarounds/**',
7278
{% endif -%}
7379
'**/{{ cookiecutter.python_package_name }}/version/**',

{{ cookiecutter.pypi_package_name }}/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ python = ">={{ cookiecutter.python_version }}"
4141
colorama = "*"
4242
fire = "*"
4343
{%- endif %}
44+
pyxdg = "*"
4445

4546
[tool.poetry.group.dev.dependencies]
4647
autopep8 = "*"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""User configuration management."""
2+
3+
import json
4+
import os
5+
from typing import Any
6+
7+
import xdg.BaseDirectory # type: ignore
8+
9+
from .logging import get_logger
10+
from .settings import PACKAGE_NAME
11+
12+
logger = get_logger(__name__)
13+
14+
15+
def config_get(key: str, default: Any | None = None) -> Any | None:
16+
"""Reads a config from the first level of a JSON file by key.
17+
18+
:param key: the name of the top-level JSON property.
19+
20+
:param default:
21+
a default value if the property is not present in the JSON
22+
or if its value is `null`.
23+
Defaults to `None`.
24+
25+
:return:
26+
the value as a Python object.
27+
Returns `None` if the JSON value is `null` or if `key` does
28+
not exist as a property at the top-level.
29+
"""
30+
path = os.path.join(config_dir(), f'{PACKAGE_NAME}.json')
31+
if not os.path.exists(path):
32+
logger.debug(
33+
'File %s does not exist; interpreting %s == ', path, default
34+
)
35+
return default
36+
with open(path, 'r', encoding='utf-8') as stream:
37+
config_dict = json.load(stream)
38+
return config_dict.get(key, default)
39+
40+
41+
def config_set(key: str, value: Any) -> None:
42+
"""Writes a key-value pair to the config JSON file.
43+
44+
Overwrites an existing property by the given key.
45+
46+
:param key: the name of the top-level JSON property to store.
47+
48+
:param value: the value as a Python object, or `None` for `null`.
49+
"""
50+
path = os.path.join(config_dir(), f'{PACKAGE_NAME}.json')
51+
if os.path.exists(path):
52+
with open(path, 'r+', encoding='utf-8') as stream:
53+
config_dict = json.load(stream) or {}
54+
if value and value == config_dict.get(key, None):
55+
logger.debug('Configuration unchanged for key %s', key)
56+
return
57+
config_dict[key] = value
58+
logger.info('Writing new value for key %s: %s', key, value)
59+
stream.seek(0)
60+
stream.truncate(0)
61+
json.dump(config_dict, stream)
62+
os.fsync(stream)
63+
else:
64+
with open(path, 'x', encoding='utf-8') as stream:
65+
logger.info(
66+
'Creating configuration file %s with value %s: %s',
67+
path,
68+
key,
69+
value,
70+
)
71+
json.dump({key: value}, stream)
72+
os.fsync(stream)
73+
74+
logger.info('New value written')
75+
76+
77+
def config_dir(subdir: str | None = None) -> str:
78+
"""Returns the configuration directory for this package.
79+
80+
Respects the XDG settings. Also creates the directory if it does
81+
not exist.
82+
83+
:param subdir: an optional subdirectory, relative to the
84+
configuration directory.
85+
86+
:return: the absolute path to the directory.
87+
"""
88+
return str(
89+
xdg.BaseDirectory.save_config_path(
90+
os.path.join(PACKAGE_NAME, subdir)
91+
if subdir
92+
else PACKAGE_NAME
93+
)
94+
)

{{ cookiecutter.pypi_package_name }}/{{ cookiecutter.python_package_name }}/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
PACKAGE_ROOT = Path(__file__).parent.absolute()
88
PYPROJECT_TOML = PROJECT_ROOT / 'pyproject.toml'
99

10+
PACKAGE_NAME = '{{ cookiecutter.pypi_package_name }}'
11+
1012
debugMode = bool(os.getenv('{{ cookiecutter.python_package_name | upper }}_DEBUG'))

0 commit comments

Comments
 (0)