Skip to content
This repository has been archived by the owner on Oct 13, 2024. It is now read-only.

Commit

Permalink
add config ui and improve sidebar (#174)
Browse files Browse the repository at this point in the history
  • Loading branch information
ReenigneArcher authored Jan 21, 2024
1 parent 7715f94 commit fc04900
Show file tree
Hide file tree
Showing 20 changed files with 1,171 additions and 121 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"@fortawesome/fontawesome-free": "6.5.1",
"bootstrap": "5.3.2",
"jquery": "3.7.1",
"parsleyjs": "2.9.2",
"plotly.js-dist-min": "2.27.1"
}
}
303 changes: 268 additions & 35 deletions pyra/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,286 @@
"""
# standard imports
import sys
from typing import Optional, List

# lib imports
from configobj import ConfigObj
from validate import Validator, ValidateError

# local imports
from pyra import definitions
from pyra import helpers
from pyra import logger
from pyra import locales

# get log
log = helpers.get_logger(name=__name__) # must use helpers.get_log due to circular import
log = logger.get_logger(name=__name__)

# get the config filename
FILENAME = definitions.Files.CONFIG

# access the config dictionary here
CONFIG = None

# increase CONFIG_VERSION default when changing default values
# localization
_ = locales.get_text()

# increase CONFIG_VERSION default and max when changing default values
# then do `if CONFIG_VERSION == x:` something to change the old default value to the new default value
# then update the CONFIG_VERSION number
_CONFIG_SPEC = [
'[Hidden]',
'CONFIG_VERSION = integer(min=0, default=0)',
'FIRST_RUN_COMPLETE = boolean(default=False)', # todo
'[General]',
'LOCALE = option("en", "es", default="en")',
'LAUNCH_BROWSER = boolean(default=True)',
'SYSTEM_TRAY = boolean(default=True)',
'[Logging]',
'LOG_DIR = string',
'DEBUG_LOGGING = boolean(default=True)',
'[Network]',
'HTTP_HOST = string(default="0.0.0.0")',
'HTTP_PORT = integer(min=21, max=65535, default=9696)',
'HTTP_ROOT = string',
'[Updater]',
'AUTO_UPDATE = boolean(default=False)',
]

# used for log filters
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']
_WHITELIST_KEYS = ['HTTPS_KEY']

LOG_BLACKLIST = []


def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigObj:


def on_change_tray_toggle() -> bool:
"""
Toggle the tray icon.
This is needed, since ``tray_icon`` cannot be imported at the module level without a circular import.
Returns
-------
bool
``True`` if successful, otherwise ``False``.
See Also
--------
pyra.tray_icon.tray_toggle : ``on_change_tray_toggle`` is an alias of this function.
Examples
--------
>>> on_change_tray_toggle()
True
"""
Create a config file and `ConfigObj` using a config spec.
from pyra import tray_icon
return tray_icon.tray_toggle()


# types
# - section
# - boolean
# - option
# - string
# - integer
# - float
# data parsley types (Parsley validation)
# - alphanum (string)
# - email (string)
# - url (string)
# - number (float, integer)
# - integer (integer)
# - digits (string)
_CONFIG_SPEC_DICT = dict(
Info=dict(
type='section',
name=_('Info'),
description=_('For information purposes only.'),
icon='info',
CONFIG_VERSION=dict(
type='integer',
name=_('Config version'),
description=_('The configuration version.'),
default=0, # increment when updating config
min=0,
max=0, # increment when updating config
data_parsley_type='integer',
extra_class='col-md-3',
locked=True,
),
FIRST_RUN_COMPLETE=dict(
type='boolean',
name=_('First run complete'),
description=_('Todo: Indicates if the user has completed the initial setup.'),
default=False,
locked=True,
),
),
General=dict(
type='section',
name=_('General'),
description=_('General settings.'),
icon='gear',
LOCALE=dict(
type='option',
name=_('Locale'),
description=_('The localization setting to use.'),
default='en',
options=[
'en',
'es',
],
option_names=[
f'English ({_("English")})',
f'Español ({_("Spanish")})',
],
refresh=True,
extra_class='col-lg-6',
),
LAUNCH_BROWSER=dict(
type='boolean',
name=_('Launch Browser on Startup '),
description=_(f'Open browser when {definitions.Names.name} starts.'),
default=True,
),
SYSTEM_TRAY=dict(
type='boolean',
name=_('Enable System Tray Icon'),
description=_(f'Show {definitions.Names.name} shortcut in the system tray.'),
default=True,
# todo - fix circular import
on_change=on_change_tray_toggle,
),
),
Logging=dict(
type='section',
name=_('Logging'),
description=_('Logging settings.'),
icon='file-code',
LOG_DIR=dict(
type='string',
name=_('Log directory'),
advanced=True,
description=_('The directory where to store the log files.'),
data_parsley_pattern=r'^[a-zA-Z]:\\(?:\w+\\?)*$' if definitions.Platform.os_platform == 'win32'
else r'^\/(?:[^/]+\/)*$ ',
# https://regexpattern.com/windows-folder-path/
# https://regexpattern.com/linux-folder-path/
extra_class='col-lg-8',
button_directory=True,
),
DEBUG_LOGGING=dict(
type='boolean',
name=_('Debug logging'),
advanced=True,
description=_('Enable debug logging.'),
default=True,
),
),
Network=dict(
type='section',
name=_('Network'),
description=_('Network settings.'),
icon='network-wired',
HTTP_HOST=dict(
type='string',
name=_('HTTP host address'),
advanced=True,
description=_('The HTTP address to bind to.'),
default='0.0.0.0',
data_parsley_pattern=r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)[.]){3}'
r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b',
# https://codverter.com/blog/articles/tech/20190105-extract-ipv4-ipv6-ip-addresses-using-regex.html
extra_class='col-md-4',
),
HTTP_PORT=dict(
type='integer',
name=_('HTTP port'),
advanced=True,
description=_('Port to bind web server to. Note that ports below 1024 may require root.'),
default=9696,
min=21,
max=65535,
data_parsley_type='integer',
extra_class='col-md-3',
),
HTTP_ROOT=dict(
type='string',
name=_('HTTP root'),
beta=True,
description=_('Todo: The base URL of the web server. Used for reverse proxies.'),
extra_class='col-lg-6',
),
),
Updater=dict(
type='section',
name=_('Updater'),
description=_('Updater settings.'),
icon='arrows-spin',
AUTO_UPDATE=dict(
type='boolean',
name=_('Auto update'),
beta=True,
description=_(f'Todo: Automatically update {definitions.Names.name}.'),
default=False,
),
),
)


def convert_config(d: dict = _CONFIG_SPEC_DICT, _config_spec: Optional[List] = None) -> List:
"""
Convert a config spec dictionary to a config spec list.
A config spec dictionary is a custom type of dictionary that will be converted into a standard config spec list
which can later be used by ``configobj``.
Parameters
----------
d : dict
The dictionary to convert.
_config_spec : Optional[List]
This should not be set when using this function, but since this function calls itself it needs to pass in the
list that is being built in order to return the correct list.
Returns
-------
list
A list representing a configspec for ``configobj``.
Examples
--------
>>> convert_config(d=_CONFIG_SPEC_DICT)
[...]
"""
if _config_spec is None:
_config_spec = []

for k, v in d.items():
try:
v['type']
except TypeError:
pass
else:
checks = ['min', 'max', 'options', 'default']
check_value = ''

for check in checks:
try:
v[check]
except KeyError:
pass
else:
check_value += f"{', ' if check_value != '' else ''}"
if check == 'options':
for option_value in v[check]:
if check_value:
check_value += f"{', ' if not check_value.endswith(', ') else ''}"
if isinstance(option_value, str):
check_value += f'"{option_value}"'
else:
check_value += f'{option_value}'
elif isinstance(v[check], str):
check_value += f"{check}=\"{v[check]}\""
else:
check_value += f"{check}={v[check]}"

check_value = f'({check_value})' if check_value else '' # add parenthesis if there's a value

if v['type'] == 'section': # config section
_config_spec.append(f'[{k}]')
else: # int option
_config_spec.append(f"{k} = {v['type']}{check_value}")

if isinstance(v, dict):
# continue parsing nested dictionary
convert_config(d=v, _config_spec=_config_spec)

return _config_spec


def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> ConfigObj:
"""
Create a config file and `ConfigObj` using a config spec dictionary.
A config spec dictionary is a strictly formatted dictionary that will be converted into a standard config spec list
to be later used by ``configobj``.
The created config is validated against a Validator object. This function will remove keys from the user's
config.ini if they no longer exist in the config spec.
Expand All @@ -64,7 +294,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO
----------
config_file : str
Full filename of config file.
config_spec : list, default = _CONFIG_SPEC
config_spec : dict, default = _CONFIG_SPEC_DICT
Config spec to use.
Returns
Expand All @@ -82,8 +312,11 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO
>>> create_config(config_file='config.ini')
ConfigObj({...})
"""
# convert config spec dictionary to list
config_spec_list = convert_config(d=config_spec)

config = ConfigObj(
configspec=config_spec,
configspec=config_spec_list,
encoding='UTF-8',
list_values=True,
stringify=True,
Expand All @@ -99,7 +332,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO

user_config = ConfigObj(
infile=config_file,
configspec=config_spec,
configspec=config_spec_list,
encoding='UTF-8',
list_values=True,
stringify=True,
Expand Down Expand Up @@ -133,7 +366,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO
config.filename = config_file
config.write() # write the config file

if config_spec == _CONFIG_SPEC: # set CONFIG dictionary
if config_spec == _CONFIG_SPEC_DICT: # set CONFIG dictionary
global CONFIG
CONFIG = config

Expand Down
6 changes: 2 additions & 4 deletions pyra/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,12 @@ class Paths:
"""
PYRA_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(PYRA_DIR)
DATA_DIR = ROOT_DIR
BINARY_PATH = os.path.abspath(os.path.join(DATA_DIR, 'retroarcher.py'))

if Modes.FROZEN: # pyinstaller build
DATA_DIR = os.path.dirname(sys.executable)
BINARY_PATH = os.path.abspath(sys.executable)
else:
DATA_DIR = ROOT_DIR
BINARY_PATH = os.path.abspath(os.path.join(DATA_DIR, 'retroarcher.py'))

if Modes.DOCKER: # docker install
DATA_DIR = '/config' # overwrite the value that was already set

Expand Down
Loading

0 comments on commit fc04900

Please sign in to comment.