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.
This commit is contained in:
dualfroz committed 2026-08-05 20:06:41 +02:00
1 parent d81b502300
commit 4ae3a496af
19 files changed
+871 -708

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
"""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()
+110
View File
@@ -0,0 +1,110 @@
"""TTL cache in front of git, invalidated eagerly by watching .git.
Without this, Nemo would fork a git process for every folder it draws, on
every redraw. With it, a repository is queried at most once per TTL, and the
entry is dropped the moment .git changes so a commit or checkout shows up
immediately instead of after the TTL.
"""
import os
import time
import weakref
from gi.repository import Gio, GLib
from .Config import coreConfig
from .gitQuery import computeRepoInfo
from .logs import pluginLogger
logger = pluginLogger("cache")
class RepoCache:
"""Keyed by repository root path. Shared by every plugin in the family."""
def __init__(self, ttlProvider):
self._ttlProvider = ttlProvider
self._entries = {}
self._monitors = {}
self._trackedFiles = {}
self._invalidateCallbacks = []
def get(self, repoRoot):
"""Cached RepoInfo for `repoRoot`, computing it when stale or absent."""
now = time.monotonic()
entry = self._entries.get(repoRoot)
if entry is not None:
info, storedAt = entry
if (now - storedAt) < self._ttlProvider():
return info
return self.refresh(repoRoot)
def refresh(self, repoRoot):
"""Recompute now, ignoring the TTL, and store the result."""
info = computeRepoInfo(repoRoot)
self._entries[repoRoot] = (info, time.monotonic())
self._ensureWatch(repoRoot)
return info
def invalidate(self, repoRoot=None):
"""Drop one entry, or the whole cache when called without argument."""
if repoRoot is None:
roots = list(self._entries)
self._entries.clear()
else:
roots = [repoRoot] if self._entries.pop(repoRoot, None) else []
for root in roots:
self._notify(root)
def registerFileObject(self, repoRoot, nemoFile):
"""Track a live Nemo.FileInfo so it can be redrawn on git changes.
References are weak: Nemo owns the object's lifetime, we only ask it
to re-query us when the repository underneath it moves.
"""
tracked = self._trackedFiles.setdefault(repoRoot, weakref.WeakSet())
try:
tracked.add(nemoFile)
except TypeError:
logger.debug("Object for %s is not weak-referenceable", repoRoot)
def onInvalidate(self, callback):
"""Register `callback(repoRoot)`, run whenever an entry is dropped."""
self._invalidateCallbacks.append(callback)
# -- internals ----------------------------------------------------------
def _notify(self, repoRoot):
for nemoFile in list(self._trackedFiles.get(repoRoot, ())):
nemoFile.invalidate_extension_info()
for callback in list(self._invalidateCallbacks):
callback(repoRoot)
def _ensureWatch(self, repoRoot):
if repoRoot in self._monitors:
return
gitDirectory = os.path.join(repoRoot, ".git")
if not os.path.isdir(gitDirectory):
return
monitors = []
for path in (gitDirectory, os.path.join(gitDirectory, "refs", "heads")):
if not os.path.exists(path):
continue
try:
gfile = Gio.File.new_for_path(path)
monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
except GLib.Error as error:
logger.debug("Cannot watch %s: %s", path, error)
continue
monitor.connect("changed", self._onGitDirectoryChanged, repoRoot)
monitors.append(monitor)
if monitors:
self._monitors[repoRoot] = monitors
def _onGitDirectoryChanged(self, monitor, gfile, otherFile, eventType, repoRoot):
self._entries.pop(repoRoot, None)
self._notify(repoRoot)
repoCache = RepoCache(lambda: coreConfig.ttlSeconds)
+74
View File
@@ -0,0 +1,74 @@
"""The snapshot of a repository that every plugin reads."""
from collections import namedtuple
LastCommit = namedtuple("LastCommit", "hash author relativeDate subject")
class RepoInfo:
"""Everything one `git status` and one `git log -1` can tell us.
Instances are immutable in practice: the cache hands the same object to
every caller for the lifetime of a cache entry, so plugins must treat
the fields as read-only.
When `error` is set, every other field is at its zero value and must not
be displayed as fact.
"""
__slots__ = ("root", "branch", "upstream", "ahead", "behind", "hasUpstream",
"staged", "unstaged", "untracked", "lastCommit", "error")
def __init__(self, root=None):
self.root = root
self.branch = None
self.upstream = None
self.ahead = 0
self.behind = 0
self.hasUpstream = False
self.staged = 0
self.unstaged = 0
self.untracked = 0
self.lastCommit = None
self.error = None
@property
def dirty(self):
return (self.staged + self.unstaged + self.untracked) > 0
@property
def unpushed(self):
"""Commits the remote does not have, including "there is no remote"."""
return self.ahead > 0 or not self.hasUpstream
@property
def detached(self):
return self.branch in (None, "(detached)")
@property
def branchLabel(self):
return self.branch or "(detached)"
@property
def summary(self):
"""One ASCII line combining sync state and working tree state."""
if self.error:
return self.error
parts = []
if self.ahead:
parts.append("%d ahead" % self.ahead)
if self.behind:
parts.append("%d behind" % self.behind)
if not self.hasUpstream:
parts.append("no upstream")
treeParts = []
if self.staged or self.unstaged:
treeParts.append("%d changed" % (self.staged + self.unstaged))
if self.untracked:
treeParts.append("%d untracked" % self.untracked)
if treeParts:
parts.append(", ".join(treeParts))
return ", ".join(parts) if parts else "clean"
def __repr__(self):
return "<RepoInfo %s %s: %s>" % (self.root, self.branchLabel, self.summary)
+82
View File
@@ -0,0 +1,82 @@
"""nemo-repo-core: the shared foundation for repository-aware Nemo plugins.
A plugin is a single .py file in ~/.local/share/nemo-python/extensions/ that
imports this package and registers one Nemo provider. Everything expensive
or fiddly lives here: the config file, the git query layer, the cache that
keeps Nemo from forking git on every redraw, and the path handling that
turns a Nemo.FileInfo into something git can be pointed at.
Minimal plugin:
from nemoRepoCore import (coreConfig, findRepoRoot, localDirectoryPath,
repoCache, requireCore)
requireCore("1.0.0")
coreConfig.registerDefaults({"indicators": {"mine": {"enabled": True}}})
class MyProvider(GObject.GObject, Nemo.InfoProvider):
def update_file_info(self, file):
path = localDirectoryPath(file)
if path is None:
return
root = findRepoRoot(path)
if root is None:
return
repoCache.registerFileObject(root, file)
info = repoCache.get(root)
...
Stability policy for everything listed in __all__:
* Within a major version, names are never removed and signatures only ever
gain optional arguments. New helpers may appear; a plugin that asked for
1.0.0 keeps working on 1.4.0.
* A major bump may remove or reshape anything. requireCore() refuses to
load a plugin against a different major, so a mismatch is a clear error
at startup instead of a subtle misbehaviour later.
* Names not in __all__, and anything with a leading underscore, are
internal and may change in any release.
"""
from .Config import Config, coreConfig, coreDefaults, defaultConfigPath, legacyConfigPath
from .discovery import (findEnclosingRepoRoot, findRepoRoot, hasMarkerDirectory,
isRepoRoot, repoInfoFor)
from .gitQuery import computeRepoInfo, gitBinary, runGit
from .launchers import (editorAvailable, launchEditor, launchTerminal,
resolveTerminal)
from .logs import pluginLogger
from .nemoFiles import localDirectoryPath, localPath, singleLocalDirectory
from .RepoCache import RepoCache, repoCache
from .RepoInfo import LastCommit, RepoInfo
from .version import CoreVersionError, coreVersion, requireCore
__all__ = [
"Config",
"CoreVersionError",
"LastCommit",
"RepoCache",
"RepoInfo",
"computeRepoInfo",
"coreConfig",
"coreDefaults",
"coreVersion",
"defaultConfigPath",
"editorAvailable",
"findEnclosingRepoRoot",
"findRepoRoot",
"gitBinary",
"hasMarkerDirectory",
"isRepoRoot",
"launchEditor",
"launchTerminal",
"legacyConfigPath",
"localDirectoryPath",
"localPath",
"pluginLogger",
"repoCache",
"repoInfoFor",
"requireCore",
"resolveTerminal",
"runGit",
"singleLocalDirectory",
]
+55
View File
@@ -0,0 +1,55 @@
"""Deciding whether a directory is a repository, and finding which one."""
import os
from .RepoCache import repoCache
def isRepoRoot(path):
"""True when `path` itself holds the repository.
.git is a directory in a normal clone and a file in a worktree or
submodule; both count.
"""
gitPath = os.path.join(path, ".git")
return os.path.isdir(gitPath) or os.path.isfile(gitPath)
def findRepoRoot(path):
"""Repository root, but only if `path` is that root. Cheap: one stat.
This is what emblem and column plugins want, because they run for every
folder Nemo draws and must not walk the filesystem upwards.
"""
return path if isRepoRoot(path) else None
def findEnclosingRepoRoot(path, maximumDepth=64):
"""Nearest repository root at or above `path`, or None.
For on-demand work such as a context menu, where walking a handful of
parent directories is free and the user expects git actions to work from
anywhere inside their checkout.
"""
current = os.path.abspath(path)
for _ in range(maximumDepth):
if isRepoRoot(current):
return current
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
return None
def hasMarkerDirectory(path, name):
"""True when `path` directly contains a subdirectory called `name`."""
return os.path.isdir(os.path.join(path, name))
def repoInfoFor(path, walkUp=False):
"""Cached RepoInfo for the repository at (or above) `path`, else None."""
root = findEnclosingRepoRoot(path) if walkUp else findRepoRoot(path)
if root is None:
return None
return repoCache.get(root)
+94
View File
@@ -0,0 +1,94 @@
"""Everything that shells out to git lives here, and nowhere else."""
import os
import shutil
import subprocess
from .logs import pluginLogger
from .RepoInfo import LastCommit, RepoInfo
logger = pluginLogger("git")
gitBinary = shutil.which("git")
defaultTimeoutSeconds = 3
def runGit(arguments, cwd, timeoutSeconds=defaultTimeoutSeconds):
"""Run git and return stdout as text, or None when it could not run.
Never raises and never blocks Nemo for long: stderr is discarded, index
locks are avoided via GIT_OPTIONAL_LOCKS, and the call is capped by a
timeout. A None result always means "no answer", never "empty answer".
"""
if not gitBinary:
return None
environment = dict(os.environ)
environment["GIT_OPTIONAL_LOCKS"] = "0"
try:
completed = subprocess.run(
[gitBinary] + list(arguments),
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=environment,
timeout=timeoutSeconds,
)
except (subprocess.TimeoutExpired, OSError) as error:
logger.debug("git %s failed in %s: %s", " ".join(arguments), cwd, error)
return None
if completed.returncode != 0:
return None
return completed.stdout.decode("utf-8", "replace")
def _applyStatusLine(info, line):
if line.startswith("# branch.head "):
info.branch = line.split(" ", 2)[2]
elif line.startswith("# branch.upstream "):
info.upstream = line.split(" ", 2)[2]
info.hasUpstream = True
elif line.startswith("# branch.ab "):
for token in line.split(" ")[2:]:
if token.startswith("+"):
info.ahead = int(token[1:])
elif token.startswith("-"):
info.behind = int(token[1:])
elif line.startswith("1 ") or line.startswith("2 "):
indexState, worktreeState = line.split(" ", 2)[1][:2]
if indexState != ".":
info.staged += 1
if worktreeState != ".":
info.unstaged += 1
elif line.startswith("u "):
info.staged += 1
elif line.startswith("? "):
info.untracked += 1
def computeRepoInfo(repoRoot):
"""Build a fresh RepoInfo for `repoRoot`, bypassing any cache.
Plugins should normally call repoCache.get() instead; this is the
uncached primitive behind it.
"""
info = RepoInfo(repoRoot)
if not gitBinary:
info.error = "git not found in PATH"
return info
status = runGit(["status", "--porcelain=2", "--branch"], repoRoot)
if status is None:
info.error = "git status failed"
return info
for line in status.splitlines():
_applyStatusLine(info, line)
logLine = runGit(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repoRoot)
if logLine:
fields = logLine.strip("\n").split("\t")
if len(fields) == 4:
info.lastCommit = LastCommit(*fields)
return info
+106
View File
@@ -0,0 +1,106 @@
"""Starting a terminal or an editor from a Nemo menu item.
Terminal emulators disagree about the flag that means "run this command",
so the mapping below is explicit rather than assuming one form works
everywhere. Unknown emulators fall back to -e, which is the oldest and most
widely accepted spelling.
"""
import os
import shutil
import subprocess
from gi.repository import GLib
from .Config import coreConfig
from .logs import pluginLogger
logger = pluginLogger("launchers")
terminalCandidates = [
"gnome-terminal",
"x-terminal-emulator",
"tilix",
"konsole",
"xfce4-terminal",
"mate-terminal",
"kitty",
"alacritty",
"xterm",
]
terminalCommandFlags = {
"gnome-terminal": ["--"],
"mate-terminal": ["--"],
"tilix": ["-e"],
"konsole": ["-e"],
"xfce4-terminal": ["-x"],
"kitty": [],
"alacritty": ["-e"],
"xterm": ["-e"],
"x-terminal-emulator": ["-e"],
}
fallbackCommandFlags = ["-e"]
def resolveTerminal():
"""The terminal command to use: the configured one, else the first found."""
configured = coreConfig.terminalCommand
if configured and shutil.which(configured.split(" ")[0]):
return configured
for candidate in terminalCandidates:
if shutil.which(candidate):
return candidate
return None
def _commandFlagsFor(terminal):
return terminalCommandFlags.get(os.path.basename(terminal), fallbackCommandFlags)
def launchTerminal(path, shellCommand=None):
"""Open a terminal in `path`, optionally running `shellCommand` first.
The shell stays open afterwards so the user can read the output of
whatever was run and keep working in the same directory.
"""
terminal = resolveTerminal()
if not terminal:
logger.warning("No terminal emulator found; tried %s",
", ".join(terminalCandidates))
return False
parts = terminal.split(" ")
try:
if shellCommand:
script = "cd %s && %s; exec \"${SHELL:-bash}\"" % (
GLib.shell_quote(path), shellCommand)
arguments = parts + _commandFlagsFor(parts[0]) + ["bash", "-c", script]
else:
arguments = parts
subprocess.Popen(arguments, cwd=path)
except OSError as error:
logger.warning("Could not start %s: %s", terminal, error)
return False
return True
def launchEditor(path):
"""Open `path` in the configured editor command."""
editor = coreConfig.editorCommand
parts = editor.split(" ")
if not shutil.which(parts[0]):
logger.warning("Editor command %r is not in PATH", editor)
return False
try:
subprocess.Popen(parts + [path])
except OSError as error:
logger.warning("Could not start editor %r: %s", editor, error)
return False
return True
def editorAvailable():
"""True when the configured editor command can actually be started."""
return shutil.which(coreConfig.editorCommand.split(" ")[0]) is not None
+54
View File
@@ -0,0 +1,54 @@
"""One logging setup shared by the core and every plugin built on it."""
import logging
import os
import sys
_rootLogger = logging.getLogger("nemo-repo")
_handlerAttached = False
_levelNames = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"warn": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
def _attachHandler():
global _handlerAttached
if _handlerAttached:
return
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(name)s: %(levelname)s: %(message)s"))
_rootLogger.addHandler(handler)
_rootLogger.propagate = False
_rootLogger.setLevel(_levelFromEnvironment())
_handlerAttached = True
def _levelFromEnvironment():
requested = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
return _levelNames.get(requested, logging.WARNING)
def applyLogLevel(name):
"""Set the level for the whole `nemo-repo` logger tree.
The NEMO_REPO_LOG environment variable always wins, so a user can raise
verbosity for one Nemo session without editing the config file.
"""
_attachHandler()
fromEnvironment = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
if fromEnvironment in _levelNames:
_rootLogger.setLevel(_levelNames[fromEnvironment])
return
_rootLogger.setLevel(_levelNames.get(str(name).strip().lower(), logging.WARNING))
def pluginLogger(name):
"""Return the logger a plugin should use, e.g. pluginLogger("emblems")."""
_attachHandler()
return _rootLogger.getChild(name)
+36
View File
@@ -0,0 +1,36 @@
"""Turning the Nemo.FileInfo objects handed to a provider into local paths.
Every provider needs the same two guards before it can do anything useful:
the item must live on the local filesystem, and for repository work it must
be a directory. Getting either wrong means running git against a path that
does not exist, or against an ftp:// URI.
"""
from gi.repository import GLib
def localPath(nemoFile):
"""Filesystem path for `nemoFile`, or None if it is not a local file."""
if nemoFile.get_uri_scheme() != "file":
return None
try:
return GLib.filename_from_uri(nemoFile.get_uri())[0]
except GLib.Error:
return None
def localDirectoryPath(nemoFile):
"""Filesystem path for `nemoFile` when it is a local directory, else None."""
if not nemoFile.is_directory():
return None
return localPath(nemoFile)
def singleLocalDirectory(files):
"""Path of the one selected local directory, or None for any other case.
Property pages and single-target menu items all want exactly this.
"""
if len(files) != 1:
return None
return localDirectoryPath(files[0])
+47
View File
@@ -0,0 +1,47 @@
"""Version of the core library and the compatibility check plugins call."""
coreVersion = "1.0.0"
class CoreVersionError(RuntimeError):
"""Raised when the installed core cannot satisfy a plugin's requirement."""
def parseVersion(text):
"""Turn "1.2.3" into (1, 2, 3). Missing components count as zero."""
parts = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def satisfies(installed, minimum):
"""True when `installed` can serve a plugin that asked for `minimum`.
The rule is plain semantic versioning: the major component must match
exactly, and the rest must be greater than or equal to the requirement.
"""
installedParts = parseVersion(installed)
minimumParts = parseVersion(minimum)
if installedParts[0] != minimumParts[0]:
return False
return installedParts >= minimumParts
def requireCore(minimumVersion):
"""Assert that this core is new enough for the calling plugin.
Call it once, at import time, before touching any other core symbol.
Nemo logs the resulting exception and skips the extension, which is the
behaviour we want: a plugin built against a newer core must not load
half-working.
"""
if not satisfies(coreVersion, minimumVersion):
raise CoreVersionError(
"nemo-repo-core %s cannot satisfy the required version %s; "
"update the core library" % (coreVersion, minimumVersion)
)
return coreVersion