keep working on new config handling

This commit is contained in:
Jonathan Wren 2023-07-01 17:14:45 -07:00
parent 2543260a41
commit 3f3354b7c0
No known key found for this signature in database
8 changed files with 302 additions and 281 deletions

View file

@ -1,228 +0,0 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html
import argparse
import logging
import os
from typing import Any
from typing import Callable
import colorama
from rich.pretty import pretty_repr
from ruamel.yaml import YAML
from ruamel.yaml import constructor
from jrnl import __version__
from jrnl.exception import JrnlException
from jrnl.messages import Message
from jrnl.messages import MsgStyle
from jrnl.messages import MsgText
from jrnl.output import list_journals
from jrnl.output import print_msg
from jrnl.path import get_config_path
from jrnl.path import get_default_journal_path
# Constants
DEFAULT_JOURNAL_KEY = "default"
YAML_SEPARATOR = ": "
YAML_FILE_ENCODING = "utf-8"
def make_yaml_valid_dict(input: list) -> dict:
"""
Convert a two-element list of configuration key-value pair into a flat dict.
The dict is created through the yaml loader, with the assumption that
"input[0]: input[1]" is valid yaml.
:param input: list of configuration keys in dot-notation and their respective values.
:type input: list
:return: A single level dict of the configuration keys in dot-notation and their respective desired values
:rtype: dict
"""
assert len(input) == 2
# yaml compatible strings are of the form Key:Value
yamlstr = YAML_SEPARATOR.join(input)
runtime_modifications = YAML(typ="safe").load(yamlstr)
return runtime_modifications
def save_config(config: dict, alt_config_path: str | None = None) -> None:
"""Supply alt_config_path if using an alternate config through --config-file."""
config["version"] = __version__
yaml = YAML(typ="safe")
yaml.default_flow_style = False # prevents collapsing of tree structure
with open(
alt_config_path if alt_config_path else get_config_path(),
"w",
encoding=YAML_FILE_ENCODING,
) as f:
yaml.dump(config, f)
def get_default_config() -> dict[str, Any]:
return {
"version": __version__,
"journals": {"default": {"journal": get_default_journal_path()}},
"editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
"encrypt": False,
"template": False,
"default_hour": 9,
"default_minute": 0,
"timeformat": "%F %r",
"tagsymbols": "#@",
"highlight": True,
"linewrap": 79,
"indent_character": "|",
"colors": {
"body": "none",
"date": "none",
"tags": "none",
"title": "none",
},
}
def get_default_colors() -> dict[str, Any]:
return {
"body": "none",
"date": "black",
"tags": "yellow",
"title": "cyan",
}
def scope_config(config: dict, journal_name: str) -> dict:
if journal_name not in config["journals"]:
return config
config = config.copy()
journal_conf = config["journals"].get(journal_name)
if type(journal_conf) is dict:
# We can override the default config on a by-journal basis
logging.debug(
"Updating configuration with specific journal overrides:\n%s",
pretty_repr(journal_conf),
)
config.update(journal_conf)
else:
# But also just give them a string to point to the journal file
config["journal"] = journal_conf
logging.debug("Scoped config:\n%s", pretty_repr(config))
return config
def verify_config_colors(config: dict) -> bool:
"""
Ensures the keys set for colors are valid colorama.Fore attributes, or "None"
:return: True if all keys are set correctly, False otherwise
"""
all_valid_colors = True
for key, color in config["colors"].items():
upper_color = color.upper()
if upper_color == "NONE":
continue
if not getattr(colorama.Fore, upper_color, None):
print_msg(
Message(
MsgText.InvalidColor,
MsgStyle.NORMAL,
{
"key": key,
"color": color,
},
)
)
all_valid_colors = False
return all_valid_colors
def load_config(config_path: str) -> dict:
"""Tries to load a config file from YAML."""
try:
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml.allow_duplicate_keys = False
return yaml.load(f)
except constructor.DuplicateKeyError as e:
print_msg(
Message(
MsgText.ConfigDoubleKeys,
MsgStyle.WARNING,
{
"error_message": e,
},
)
)
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml.allow_duplicate_keys = True
return yaml.load(f)
def is_config_json(config_path: str) -> bool:
with open(config_path, "r", encoding="utf-8") as f:
config_file = f.read()
return config_file.strip().startswith("{")
def update_config(
config: dict, new_config: dict, scope: str | None, force_local: bool = False
) -> None:
"""Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file,
or within the scope"""
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict
config["journals"][scope] = {"journal": config["journals"][scope]}
config["journals"][scope].update(new_config)
else:
config.update(new_config)
def get_journal_name(args: argparse.Namespace, config: dict) -> argparse.Namespace:
args.journal_name = DEFAULT_JOURNAL_KEY
# The first arg might be a journal name
if args.text:
potential_journal_name = args.text[0]
if potential_journal_name[-1] == ":":
potential_journal_name = potential_journal_name[0:-1]
if potential_journal_name in config["journals"]:
args.journal_name = potential_journal_name
args.text = args.text[1:]
logging.debug("Using journal name: %s", args.journal_name)
return args
def cmd_requires_valid_journal_name(func: Callable) -> Callable:
def wrapper(args: argparse.Namespace, config: dict, original_config: dict):
validate_journal_name(args.journal_name, config)
func(args=args, config=config, original_config=original_config)
return wrapper
def validate_journal_name(journal_name: str, config: dict) -> None:
if journal_name not in config["journals"]:
raise JrnlException(
Message(
MsgText.NoNamedJournal,
MsgStyle.ERROR,
{
"journal_name": journal_name,
"journals": list_journals(config),
},
),
)

View file

@ -4,15 +4,21 @@
import logging import logging
from abc import ABC from abc import ABC
from abc import abstractmethod from abc import abstractmethod
from typing import Any
from rich.pretty import pretty_repr
class BaseConfigReader(ABC): class BaseConfigReader(ABC):
def __init__(self): def __init__(self):
logging.debug("start") logging.debug("start")
self.config: dict = {} self.config: dict[str, Any] = {}
def __str__(self):
return pretty_repr(self.config)
@abstractmethod @abstractmethod
def read(self): def read(self):
"""Needs to set self.config"""
pass pass
def get_config(self): def get_config(self):

View file

@ -1,20 +1,38 @@
# Copyright © 2012-2023 jrnl contributors # Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
from collections import abc import logging
from jrnl.exception import JrnlConfigException
class Config(abc.MutableMapping): from jrnl.exception import JrnlException
def __init__(self, configs): from jrnl.config.BaseConfigReader import BaseConfigReader
pass
def add_config(config, priority): class Config():
def __init__(self):
self.configs: list[dict[str, list[BaseConfigReader] | bool]] = []
def sub_configs(): def add_config(self, readers: list[BaseConfigReader], required: bool = False):
return [ self.configs.append({
one, "readers" : readers,
two, "required": required,
three, })
]
def read(self):
for config in self.configs:
found = False
for reader in config["readers"]:
keep_going = False
try:
reader.read()
found = True
except JrnlConfigException as e:
print(e)
keep_going = True
if not keep_going:
break
logging.debug(f"config read: {reader}")
if config["required"] and not found:
raise JrnlException

View file

@ -2,21 +2,36 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
import logging import logging
from .BaseConfigReader import BaseConfigReader from .BaseConfigReader import BaseConfigReader
from pathlib import PurePath
class DefaultConfigReader(BaseConfigReader): class DefaultConfigReader(BaseConfigReader):
def __init__(self, filename: str): def __init__(self, *args, **kwargs):
logging.debug("start") logging.debug("start")
super() super().__init__(*args, **kwargs)
self.filename: PurePath = PurePath(filename)
def read(self): def read(self):
self._parse_args() logging.debug("start read")
# do some actual reading self.config = {
# TODO: Uncomment these lines
def _parse_args(self): # "version": __version__,
# read self.args # "journals": {"default": {"journal": get_default_journal_path()}},
# update self.cofig somehow # "editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
pass "encrypt": False,
"template": False,
"default_hour": 9,
"default_minute": 0,
"timeformat": "%F %r",
"tagsymbols": "#@",
"highlight": True,
"linewrap": 79,
"indent_character": "|",
"colors": {
"body": "none",
"date": "none",
"tags": "none",
"title": "none",
},
}

View file

@ -3,20 +3,51 @@
import logging import logging
from .BaseConfigReader import BaseConfigReader from .BaseConfigReader import BaseConfigReader
from jrnl.exception import JrnlConfigException
from jrnl.path import expand_path
from pathlib import PurePath from pathlib import PurePath
from ruamel.yaml import YAML
from ruamel.yaml import constructor
YAML_SEPARATOR = ": "
YAML_FILE_ENCODING = "utf-8"
def load_config(config_path: str) -> dict:
"""Tries to load a config file from YAML."""
try:
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml.allow_duplicate_keys = False
return yaml.load(f)
except constructor.DuplicateKeyError as e:
print_msg(
Message(
MsgText.ConfigDoubleKeys,
MsgStyle.WARNING,
{
"error_message": e,
},
)
)
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml.allow_duplicate_keys = True
return yaml.load(f)
class FileConfigReader(BaseConfigReader): class FileConfigReader(BaseConfigReader):
def __init__(self, filename: str): def __init__(self, filename: str):
logging.debug("start") logging.debug("start")
super() super()
self.filename: PurePath = PurePath(filename) self.filename: PurePath = PurePath(expand_path(filename))
def read(self): def read(self):
self._parse_args() logging.debug(f"start read for {self.filename}")
# do some actual reading
def _parse_args(self): try:
# read self.args self._raw_config_file = read_file(self.filename)
# update self.cofig somehow # do some tests on config file contents
pass # self.config = load_config(expand_path(self.filename))
except FileNotFoundError:
raise JrnlConfigException("File is missing")

View file

@ -2,35 +2,211 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
from .Config import Config from .Config import Config
from .BaseConfigReader import BaseConfigReader
from .DefaultConfigReader import DefaultConfigReader from .DefaultConfigReader import DefaultConfigReader
from .FileConfigReader import FileConfigReader from .FileConfigReader import FileConfigReader
from .ArgsConfigReader import ArgsConfigReader from jrnl.path import get_config_path
def get_config(args): def get_config(args):
config = Config() config = Config()
try: # these are in ascending priority (last one has most priority)
# these are in ascending priority (last one has most priority) config.add_config([
config.add_config([ DefaultConfigReader(),
DefaultConfigReader(), ])
])
if args.config_file_path:
config.add_config([ config.add_config([
FileConfigReader(args.config_file), FileConfigReader(args.config_file_path),
FileConfigReader(config.get_config_path()), ], required=True)
FileConfigReader(jrnlV1Path), else:
config.add_config([
FileConfigReader(get_config_path()),
FileConfigReader(os.path.join(home_dir(), ".jrnl_config")),
], required=True) ], required=True)
config.add_config([ # config.add_config([
ArgsConfigReader(args.config_override), # ArgsConfigReader(args.config_override),
]) # ])
# config.add_config(EnvConfigReader(env.whatever)) # config.add_config(EnvConfigReader(env.whatever))
config.validate()
except e: config.read()
# TODO: catch warnings instead of fatal exceptions
return config return config
# --- OLD CODE HERE --- #
import argparse
import logging
from typing import Any
from typing import Callable
import colorama
from rich.pretty import pretty_repr
from ruamel.yaml import YAML
from jrnl import __version__
from jrnl.exception import JrnlException
from jrnl.messages import Message
from jrnl.messages import MsgStyle
from jrnl.messages import MsgText
from jrnl.output import list_journals
from jrnl.output import print_msg
from jrnl.path import get_config_path
# Constants
DEFAULT_JOURNAL_KEY = "default"
def make_yaml_valid_dict(input: list) -> dict:
"""
Convert a two-element list of configuration key-value pair into a flat dict.
The dict is created through the yaml loader, with the assumption that
"input[0]: input[1]" is valid yaml.
:param input: list of configuration keys in dot-notation and their respective values.
:type input: list
:return: A single level dict of the configuration keys in dot-notation and their respective desired values
:rtype: dict
"""
assert len(input) == 2
# yaml compatible strings are of the form Key:Value
yamlstr = YAML_SEPARATOR.join(input)
runtime_modifications = YAML(typ="safe").load(yamlstr)
return runtime_modifications
def save_config(config: dict, alt_config_path: str | None = None) -> None:
"""Supply alt_config_path if using an alternate config through --config-file."""
config["version"] = __version__
yaml = YAML(typ="safe")
yaml.default_flow_style = False # prevents collapsing of tree structure
with open(
alt_config_path if alt_config_path else get_config_path(),
"w",
encoding=YAML_FILE_ENCODING,
) as f:
yaml.dump(config, f)
def get_default_colors() -> dict[str, Any]:
return {
"body": "none",
"date": "black",
"tags": "yellow",
"title": "cyan",
}
def scope_config(config: dict, journal_name: str) -> dict:
if journal_name not in config["journals"]:
return config
config = config.copy()
journal_conf = config["journals"].get(journal_name)
if type(journal_conf) is dict:
# We can override the default config on a by-journal basis
logging.debug(
"Updating configuration with specific journal overrides:\n%s",
pretty_repr(journal_conf),
)
config.update(journal_conf)
else:
# But also just give them a string to point to the journal file
config["journal"] = journal_conf
logging.debug("Scoped config:\n%s", pretty_repr(config))
return config
def verify_config_colors(config: dict) -> bool:
"""
Ensures the keys set for colors are valid colorama.Fore attributes, or "None"
:return: True if all keys are set correctly, False otherwise
"""
all_valid_colors = True
for key, color in config["colors"].items():
upper_color = color.upper()
if upper_color == "NONE":
continue
if not getattr(colorama.Fore, upper_color, None):
print_msg(
Message(
MsgText.InvalidColor,
MsgStyle.NORMAL,
{
"key": key,
"color": color,
},
)
)
all_valid_colors = False
return all_valid_colors
def is_config_json(config_path: str) -> bool:
with open(config_path, "r", encoding="utf-8") as f:
config_file = f.read()
return config_file.strip().startswith("{")
def update_config(
config: dict, new_config: dict, scope: str | None, force_local: bool = False
) -> None:
"""Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file,
or within the scope"""
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict
config["journals"][scope] = {"journal": config["journals"][scope]}
config["journals"][scope].update(new_config)
else:
config.update(new_config)
def get_journal_name(args: argparse.Namespace, config: dict) -> argparse.Namespace:
args.journal_name = DEFAULT_JOURNAL_KEY
# The first arg might be a journal name
if args.text:
potential_journal_name = args.text[0]
if potential_journal_name[-1] == ":":
potential_journal_name = potential_journal_name[0:-1]
if potential_journal_name in config["journals"]:
args.journal_name = potential_journal_name
args.text = args.text[1:]
logging.debug("Using journal name: %s", args.journal_name)
return args
def cmd_requires_valid_journal_name(func: Callable) -> Callable:
def wrapper(args: argparse.Namespace, config: dict, original_config: dict):
validate_journal_name(args.journal_name, config)
func(args=args, config=config, original_config=original_config)
return wrapper
def validate_journal_name(journal_name: str, config: dict) -> None:
if journal_name not in config["journals"]:
raise JrnlException(
Message(
MsgText.NoNamedJournal,
MsgStyle.ERROR,
{
"journal_name": journal_name,
"journals": list_journals(config),
},
),
)

View file

@ -5,14 +5,12 @@ import logging
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from jrnl import install
from jrnl import plugins from jrnl import plugins
from jrnl import time from jrnl import time
from jrnl.config import get_config from jrnl.config import get_config
from jrnl.config import DEFAULT_JOURNAL_KEY from jrnl.config import DEFAULT_JOURNAL_KEY
from jrnl.config import get_config_path from jrnl.config import get_config_path
from jrnl.config import get_journal_name from jrnl.config import get_journal_name
from jrnl.config import scope_config
from jrnl.editor import get_text_from_editor from jrnl.editor import get_text_from_editor
from jrnl.editor import get_text_from_stdin from jrnl.editor import get_text_from_stdin
from jrnl.editor import read_template_file from jrnl.editor import read_template_file
@ -23,7 +21,6 @@ from jrnl.messages import MsgStyle
from jrnl.messages import MsgText from jrnl.messages import MsgText
from jrnl.output import print_msg from jrnl.output import print_msg
from jrnl.output import print_msgs from jrnl.output import print_msgs
from jrnl.override import apply_overrides
if TYPE_CHECKING: if TYPE_CHECKING:
from argparse import Namespace from argparse import Namespace
@ -50,8 +47,7 @@ def run(args: "Namespace"):
config = get_config(args) config = get_config(args)
if config.needs_upgrade(): raise JrnlException
upgrade.run_upgrade(config)
# old code # old code

View file

@ -22,3 +22,10 @@ class JrnlException(Exception):
def has_message_text(self, message_text: "MsgText"): def has_message_text(self, message_text: "MsgText"):
return any([m.text == message_text for m in self.messages]) return any([m.text == message_text for m in self.messages])
class JrnlConfigException(JrnlException):
"""For catching something something"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)