Files
nemo-repo-core/nemoRepoCore/Config.py
T
dualfroz 4ae3a496af refactor: extract the shared foundation into an installable library
The single file extension carried 260 lines of infrastructure under its
four Nemo providers: the config file with live reload, the git query
layer, the TTL cache with .git watching, and repository detection. Any
second extension wanting repository awareness had to repeat all of it.

Move that foundation into a nemoRepoCore package with a deliberate plugin
API rather than whatever the providers happened to leave behind:

  * Config gains layered defaults, so a plugin registers its own keys and
    an empty config file is still a working one
  * RepoInfo gains a root, a named last commit tuple and a summary string
  * RepoCache gains refresh, invalidate and an invalidation callback
  * discovery separates the cheap root check used on every drawn folder
    from the upward walk used by on demand work such as menus
  * nemoFiles collapses the uri scheme and directory guards every
    provider was repeating
  * launchers learn the per emulator flag for running a command
  * version negotiation lets a plugin state the core it was built for

The config file moves to nemo-repo-core.json and is seeded from the old
nemo-repo-emblems.json when one exists, so no key changes name and
nothing needs editing by hand.
2026-08-05 20:06:41 +02:00

188 lines
6.0 KiB
Python

"""The single JSON config file shared by the core and all of its plugins.
Layout on disk (every key optional, plugins fill in their own defaults):
{
"cache_ttl_seconds": 5,
"editor_command": "code",
"terminal_command": null,
"log_level": "warning",
"indicators": { ... }, // owned by whichever plugin registers them
"actions": { ... }
}
Keys use the JSON spelling they have always had, so a config written for the
pre-split single-file extension keeps working unchanged.
"""
import copy
import json
import os
import shutil
from gi.repository import Gio, GLib
from .logs import applyLogLevel, pluginLogger
logger = pluginLogger("config")
configFileName = "nemo-repo-core.json"
legacyConfigFileName = "nemo-repo-emblems.json"
coreDefaults = {
"cache_ttl_seconds": 5,
"editor_command": "code",
"terminal_command": None,
"log_level": "warning",
}
def configDirectory():
return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
def defaultConfigPath():
override = os.environ.get("NEMO_REPO_CORE_CONFIG")
if override:
return os.path.expanduser(override)
return os.path.join(configDirectory(), configFileName)
def legacyConfigPath():
return os.path.join(configDirectory(), legacyConfigFileName)
def mergeDictionaries(base, overlay):
"""Recursive merge; `overlay` wins, dictionaries merge instead of replace."""
merged = copy.deepcopy(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = mergeDictionaries(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
class Config:
"""Live view of the config file, reloaded whenever the file changes.
Effective values are the merge of three layers, later ones winning:
core defaults, defaults registered by plugins, then the user's file.
"""
def __init__(self, path=None):
self.path = path or defaultConfigPath()
self.defaults = copy.deepcopy(coreDefaults)
self.userData = {}
self.data = copy.deepcopy(self.defaults)
self._changeCallbacks = []
self._monitor = None
self._createFileIfMissing()
self.reload()
self._watchFile()
# -- layers ------------------------------------------------------------
def registerDefaults(self, defaults):
"""Add a plugin's default block, then recompute effective values.
Safe to call from several plugins; blocks are deep-merged, so two
plugins can both contribute keys to the same section.
"""
self.defaults = mergeDictionaries(self.defaults, defaults)
self._recompute()
def _recompute(self):
self.data = mergeDictionaries(self.defaults, self.userData)
applyLogLevel(self.data.get("log_level", "warning"))
# -- reading -----------------------------------------------------------
def section(self, name):
"""Merged sub-object for `name`, or an empty dict when absent."""
value = self.data.get(name)
return value if isinstance(value, dict) else {}
def value(self, name, fallback=None):
current = self.data.get(name)
return fallback if current is None else current
def flag(self, name, fallback=False):
return bool(self.data.get(name, fallback))
def sectionFlag(self, sectionName, key, fallback=False):
entry = self.section(sectionName).get(key, fallback)
if isinstance(entry, dict):
return bool(entry.get("enabled", fallback))
return bool(entry)
@property
def ttlSeconds(self):
try:
return max(0.0, float(self.data.get("cache_ttl_seconds", 5)))
except (TypeError, ValueError):
return 5.0
@property
def editorCommand(self):
return self.data.get("editor_command") or "code"
@property
def terminalCommand(self):
return self.data.get("terminal_command")
# -- change notification ------------------------------------------------
def onChange(self, callback):
"""Register `callback()` to run after every successful reload."""
self._changeCallbacks.append(callback)
# -- file handling -------------------------------------------------------
def _createFileIfMissing(self):
if os.path.exists(self.path):
return
directory = os.path.dirname(self.path)
try:
os.makedirs(directory, exist_ok=True)
legacy = legacyConfigPath()
if os.path.isfile(legacy):
shutil.copyfile(legacy, self.path)
logger.info("Migrated config from %s to %s", legacy, self.path)
return
with open(self.path, "w") as handle:
json.dump(coreDefaults, handle, indent=2)
handle.write("\n")
except OSError as error:
logger.warning("Could not create %s: %s", self.path, error)
def reload(self):
try:
with open(self.path) as handle:
loaded = json.load(handle)
except FileNotFoundError:
loaded = {}
except (OSError, ValueError) as error:
logger.warning("Ignoring unreadable config %s: %s", self.path, error)
return
self.userData = loaded if isinstance(loaded, dict) else {}
self._recompute()
for callback in list(self._changeCallbacks):
callback()
def _watchFile(self):
try:
gfile = Gio.File.new_for_path(self.path)
self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
self._monitor.connect("changed", self._onFileChanged)
except GLib.Error as error:
logger.warning("Config file will not live-reload: %s", error)
def _onFileChanged(self, monitor, gfile, otherFile, eventType):
if eventType in (Gio.FileMonitorEvent.CHANGES_DONE_HINT,
Gio.FileMonitorEvent.CREATED):
self.reload()
coreConfig = Config()