async def main(ctx, reactor, urls_and_or_files): log = create_logger(namespace="korbenware.cli.open") config = ctx.config applications = ApplicationsRegistry(config) mime = MimeRegistry(config, applications) urls = UrlRegistry(config, applications) finder = ApplicationFinder(urls, mime) # TODO: dbus executor executor = BaseExecutor() for url_or_file in urls_and_or_files: try: app = finder.get_by_url_or_file(url_or_file) except OpenError: log.error( "Unable to find a suitable application for opening {url_or_file}", # noqa url_or_file=url_or_file, ) else: log.info( "Opening {url_or_file} with application {application}...", url_or_file=url_or_file, application=app.filename, ) executor.run_xdg_application(app, exec_key_fields=exec_key_fields( app, url_or_file))
class MonitoringExecutor(BaseExecutor): log = create_logger() def __init__(self, reactor=None): super().__init__() self.monitor = ProcessMonitor(log=self.log, reactor=reactor) def start(self): self.log.info("Starting monitoring service...") self.monitor.startService() def stop(self): self.log.info("Stopping monitoring service...") self.monitor.stopService() def run_argv( self, process_name, argv, *, monitor=False, restart=False, cleanup=True, monitor_params=None, env=None, cwd=None, ): if not monitor: return super().run_argv(process_name, argv, env=env, cwd=cwd) env = load_env(env) cwd = cwd or os.getcwd() monitor_params = monitor_params or dict() self.log.info( "Adding {process_name} using {argv} as a monitored process...", # noqa process_name=process_name, argv=argv, restart=restart, cleanup=cleanup, monitor_params=monitor_params, env=env, cwd=cwd, ) self.monitor.addProcess( process_name, argv, env=env, cwd=cwd, restart=restart, cleanup=cleanup ) def start_process(self, name): self.monitor.startProcess(name) def stop_process(self, name): self.monitor.stopProcess(name) def restart_process(self, name): self.monitor.restartProcess(name) def has_process(self, name): return self.monitor.hasProcess(name)
class UrlRegistry: log = create_logger() def __init__(self, config, applications): self.lookup = dict() for scheme, desktop_file in config.urls.items(): self.log.debug( "Registering desktop application {application_name} as the opener for {scheme}:// urls...", # noqa application_name=desktop_file, scheme=scheme, ) self.lookup[scheme] = desktop_file self.applications = applications def get_application_by_url(self, url): parsed = urllib.parse.urlparse(url) return self.get_application_by_scheme(parsed.scheme) def get_application_by_scheme(self, scheme): if scheme not in self.lookup: return None key = self.lookup[scheme] return self.applications.entries.get(key)
class BaseExecutor: log = create_logger() def run_argv( self, process_name, argv, *, env=None, cwd=None, ): env = load_env(env) cwd = cwd or os.getcwd() self.log.info( "Spawning {process_name} using {argv} as a detached process...", process_name=process_name, argv=argv, env=env, cwd=cwd, ) spawn_detached(argv, env=env, cwd=cwd) def run_config(self, process_name, config): kwargs = asdict(config) for key in list(kwargs.keys()): if not kwargs.get(key, None) and not isinstance( kwargs.get(key, None), bool ): del kwargs[key] return self.run_argv(process_name, **kwargs) def run_exec_key( self, process_name, exec_key, *, exec_key_fields=None, env=None, cwd=None, **kwargs, ): argv = exec_key.build_argv(exec_key_fields) self.run_argv(process_name, argv, env=env, cwd=cwd, **kwargs) def run_command(self, process_name, raw, **kwargs): return self.run_exec_key(process_name, ExecKey(raw), **kwargs) def run_xdg_executable(self, executable, **kwargs): self.log.debug( "Running XDG executable {filename}...", filename=executable.filename or ("<unknown filename>"), ) return self.run_exec_key(executable.filename, executable.exec_key, **kwargs) def run_xdg_desktop_entry(self, entry, **kwargs): return self.run_xdg_executable(Executable.from_desktop_entry(entry), **kwargs) def run_xdg_application(self, app, **kwargs): return self.run_xdg_executable(app.executable, **kwargs)
class ApplicationExecutor(MonitoringExecutor): log = create_logger() def __init__(self, *, reactor=None, applications=None): super().__init__(reactor=reactor) self.applications = applications def run_xdg_application_by_name(self, filename, **kwargs): self.log.debug( "Running XDG application {filename} by name...", filename=filename ) app = self.applications.entries[filename] return self.run_xdg_application(app, **kwargs)
class ApplicationsRegistry: log = create_logger() def __init__(self, config, key="applications", cls=Application): self.directories = getattr(config, key).directories self.entry_sets = load_application_sets(self.directories, self.log, cls) self.entries = dict() for filename, entry_set in self.entry_sets.items(): entry = entry_set.coalesce( skip_unparsed=getattr(config, key).skip_unparsed, skip_invalid=getattr(config, key).skip_invalid, ) if entry: self.entries[filename] = entry
async def main(ctx, reactor): config = ctx.config log = create_logger(namespace="korbenware.cli.menu") executor = BaseExecutor() xdg_menu = xdg.Menu.parse(config.menu.filename) session = menu_session(ctx.command.hed, ctx.command.dek, xdg_menu) desktop_entry = await session.run() if not desktop_entry: log.info( "Looks like you didn't end up choosing an item from the menu; doing nothing" # noqa ) else: log.info("Opening {name}...", name=desktop_entry.getName()) executor.run_xdg_desktop_entry(desktop_entry)
class AutostartRegistry(ApplicationsRegistry): log = create_logger() def __init__(self, config): super().__init__(config, key="autostart", cls=Autostart) self.environment_name = config.autostart.environment_name self.autostart_entries = dict() for name, entry in self.entries.items(): if entry.should_autostart(self.environment_name): self.log.debug( "Entry {filename} elligible for autostart", filename=entry.filename ) self.autostart_entries[entry.filename] = entry else: self.log.warn( "Entry {filename} not eligible for autostart", filename=entry.filename, conditions=entry.autostart_conditions(self.environment_name), ) def init_executor(self, executor, monitor=True, cleanup=False, env=None, cwd=None): for name, entry in self.autostart_entries.items(): self.log.info( "Adding {name} to executor...", name=name, executor=executor, monitor=monitor, cleanup=cleanup, env=env, cwd=cwd, ) executor.run_xdg_application( entry, monitor=monitor, cleanup=cleanup, env=env, cwd=cwd )
def invoke(*args, **kwargs): # Starting from here is much the same as click.Context.invoke... self, callback = args[:2] ctx = self if isinstance(callback, click.Command): other_cmd = callback callback = other_cmd.callback ctx = Context(other_cmd, info_name=other_cmd.name, parent=self) if callback is None: raise TypeError( "The given command does not have a callback that can be invoked." ) for param in other_cmd.params: if param.name not in kwargs and param.expose_value: kwargs[param.name] = param.get_default(ctx) args = args[2:] if not iscoroutinefunction(callback): # In our version of invoke, we want custom logging of exits and # failures, so we try/except around the callback, ignoring a big # list of exceptions with special behavior in Click and Python, # and log accordingly. def runner(): try: rv = callback(*args, **kwargs) except ( EOFError, KeyboardInterrupt, SystemExit, ClickException, OSError, Exit, Abort, ): raise except: # noqa self._log_failure() self.exit(1) else: self._log_ok() self._run_deferred_actions() return rv else: # We also handle cases where the command is a Twisted coroutine - # in these scenarios we do basically the same thing as before, # except inside of a coroutine function. async def async_runner(*args, **kwargs): try: rv = await callback(*args, **kwargs) except ( EOFError, KeyboardInterrupt, SystemExit, ClickException, OSError, Exit, Abort, ): raise except: # noqa self._log_failure() # Click's default exit mechanism is raising a special Exit # exception, which it can't capture in an async context. # Instead, we assume that its exit behaviors only matter # before "async things happen" and manually exit(1). sys.exit(1) else: self._log_ok() self._run_deferred_actions() return rv # This coroutine function is ran using task.react and ensureDeferred # - note that the return value that Click receives is that of # task.react and not of our coroutine. Such is life. def runner(): return react(lambda reactor: ensureDeferred( async_runner(reactor, *args, **kwargs))) # These two context managers are as in Click... with augment_usage_errors(self): with ctx: # If necessary, we load the korbenware config, set up a # logger for the context and configure a CLI observer with # appropriate verbosity. If this is a child context, then it # should already have these properties. if not self.config: try: self.config = load_config() self.config_exc = None except (NoConfigurationFoundError, TomlDecodeError) as exc: self.config = None self.config_exc = exc if not self.log: self.log = create_logger(namespace="korbenware.cli.base") if not self.observer: self.observer = self.command.observer_factory( self.config, verbosity=kwargs.pop("verbose", None)) publisher.addObserver(self.observer) else: del kwargs["verbose"] if not self.parent: self.log.info("It worked if it ends with OK 👍") greet_fields = [ ("hed", self.command.hed), ("subhed", self.command.subhed), ] if self.command.dek: greet_fields.append(("dek", self.command.dek)) max_len = max(len(value) for name, value in greet_fields) self.log.info("┏━" + ("━" * max_len) + "━┓") for name, value in greet_fields: log_format = ("┃ {" + name + "}" + (" " * (max_len - len(value))) + " ┃") self.log.info(log_format, **{name: value}) self.log.info("┗━" + ("━" * max_len) + "━┛") if self.config_exc: raise self.config_exc return runner()
class ProcessMonitor(BaseMonitor, EventEmitter): """ A subclass of twisted.runner.procmon#ProcessMonitor. While it implements the same interfaces, it also have a number of extensions and behavioral changes: * Processes can individually be set to restart or, crucially, *not* restart - this is the primary use case around the "autostart" freedesktop standard. The default is to not restart; it must be explicitly enabled. * Processes accept individual arguments for threshold, killTime, minRestartDelay and maxRestartDelay. The restart behavior, when enabled, is otherwise the same as in twisted.runner.procmon. * Emit events as a pyee TwistedEventEmitter for various lifecycle behaviors. Events: * 'startService' - The ProcessMonitor is starting. * 'serviceStarted' - The ProcessMonitor has stopped and all processes have started. * 'stopService' - The ProcessMonitor is stopping. * 'serviceStopped' - The ProcessMonitor has stopped and all processes have exited. * 'addProcess' - A process is being added. - state: ProcessState - The state of the newly-added process. * 'removeProcess' - A process is being removed. - state: ProcessState - The state of the process at the time of removal. * 'startProcess' - A process is being started. - state: ProcessState - The state of the process at the time of starting. * 'stopProcess' - A process is being stopped. - state: ProcessState - The state of the process at the time of it being stopped. * 'restartProcess' - A process has explicitly been told to restart. This event does not fire when a process exits unexpectedly, or is manually cycled by calls to stopProcess/startProcess. - state: ProcessState - the state of the process at the time of it restarting * 'connectionLost' - A process has exited. - state: ProcessState - the state of the process right before it exited * 'forceStop' - A process being stopped timed out and had to be forced to stop with a SIGKILL. - state: ProcessState - the state of the process being stopped * 'stateChange' - A process's state has changed. - state: ProcessState - the new state of the process """ restart = False log = create_logger() def __init__(self, log=None, reactor=None): if reactor: BaseMonitor.__init__(self, reactor=reactor) else: BaseMonitor.__init__(self) EventEmitter.__init__(self) self.log = log or self.log self.settings = dict() self.states = dict() def isRegistered(self, name): """ Is this process registered? """ return name in self.states def assertRegistered(self, name): """ Raises a KeyError if the process isn't registered. """ if not self.isRegistered(name): raise KeyError(f"Unrecognized process name: {name}") def _setProcessState(self, name, state): self.states[name] = state self.emit("stateChange", dict(name=name, state=state)) def getState(self, name): """ Fetch and package the internal state of the process. Note that this will always return a ProcessState even if the internal state is malformed or missing. """ self.assertRegistered(name) return ProcessState( name=name, state=self.states.get(name, None), settings=self.settings.get(name, None), ) def addProcess( self, name, args, *, env=None, cwd=None, uid=None, gid=None, restart=False, cleanup=None, threshold=None, killTime=None, minRestartDelay=None, maxRestartDelay=None, ): """ Add a new monitored process. If the service is running, start it immediately. """ env = dict() if env is None else env if name in self.states: raise KeyError(f"Process {name} already exists! Try removing it first.") state = LifecycleState.STOPPED settings = ProcessSettings(restart=restart) if restart: settings.threshold = threshold if threshold is not None else self.threshold settings.killTime = killTime if killTime is not None else self.killTime settings.minRestartDelay = ( minRestartDelay if minRestartDelay is not None else self.minRestartDelay ) settings.maxRestartDelay = ( maxRestartDelay if maxRestartDelay is not None else self.maxRestartDelay ) settings.cleanup = False else: settings.cleanup = cleanup if cleanup is not None else True self._setProcessState(name, state) self.settings[name] = settings self.emit("addProcess", self.getState(name)) super().addProcess(name, args, uid, gid, env, cwd) def removeProcess(self, name): """ Remove a process. This stops the process and then removes all state from the process monitor. This currently isn't well-tested and I suspect that code paths triggered by stopping the process may cause async race conditions. It's therefore recommended that you manually stop processes first, before exiting. """ self.emit("removeProcess", self.getState(name)) super().removeProcess(name) del self.settings[name] del self.states[name] def _allServicesRunning(self): return all(state == LifecycleState.RUNNING for state in self.states.values()) def startService(self): """ Start the service, which starts all the processes. """ self.emit("startService") super().startService() def maybe_emit(state): if self._allServicesRunning(): self.remove_listener("stateChange", maybe_emit) self.emit("serviceStarted") if self._allServicesRunning(): self.emit("serviceStarted") else: self.on("stateChange", maybe_emit) def _allServicesStopped(self): return all(state == LifecycleState.STOPPED for state in self.states.values()) def stopService(self): """ Stop the service, which stops all the processes. """ self.emit("stopService") super().stopService() def maybe_emit(state): if self._allServicesStopped(): self.emit("serviceStopped") if self._allServicesStopped(): self.emit("serviceStopped") else: self.on("stateChange", maybe_emit) def _isActive(self, name): return self.isRegistered(name) and self.states[name] in { LifecycleState.RUNNING, LifecycleState.STOPPING, } def _spawnProcess(self, *args, **kwargs): return self._reactor.spawnProcess(*args, **kwargs) def startProcess(self, name): """ Start a process. Updates the state to RUNNING. """ # Unlike in procmon, we track process status in a dict so we # should check that to see the state self.assertRegistered(name) if self._isActive(name): return self.emit("startProcess", self.getState(name)) # Should be smooth sailing - This section is the same as in procmon process = self._processes[name] proto = LoggingProtocol() proto.service = self proto.name = name self.protocols[name] = proto self.timeStarted[name] = self._reactor.seconds() self._spawnProcess( proto, process.args[0], process.args, uid=process.uid, gid=process.gid, env=process.env, path=process.cwd, ) # This is new though! self._setProcessState(name, LifecycleState.RUNNING) def connectionLost(self, name): """ Called when a monitored process exits. Overrides the base ProcessMonitor behavior to use per-process parameters, track state for external observation, and by default actually does not restart the process. """ priorState = self.states[name] settings = self.settings[name] restartSetting = settings.restart # Update our state depending on what it was when the process exited if priorState in {LifecycleState.STARTING, LifecycleState.RUNNING}: # We expected the process to be running - we should fall back to # our individual settings for restarts shouldRestart = restartSetting # State should either be RESTARTING or STOPPED self.states[name] = ( LifecycleState.RESTARTING if shouldRestart else LifecycleState.STOPPED ) elif priorState == LifecycleState.RESTARTING: # OK, we're explicitly restarting shouldRestart = True elif priorState == LifecycleState.STOPPING: # OK, we're explicitly quitting shouldRestart = False self._setProcessState(name, LifecycleState.STOPPED) elif priorState == LifecycleState.STOPPED: # This shouldn't happen but if it does we *definitely* don't want # to restart shouldRestart = False shouldCleanup = not shouldRestart and settings.cleanup self.emit("connectionLost", self.getState(name)) # This chunk is straight from procmon - this is clearing force # quit timeouts if name in self.murder: if self.murder[name].active(): self.murder[name].cancel() del self.murder[name] del self.protocols[name] # Pulling in our per-process settings... threshold = settings.threshold minRestartDelay = settings.minRestartDelay maxRestartDelay = settings.maxRestartDelay if shouldRestart: # This section is also largely copied from procmon if self._reactor.seconds() - self.timeStarted[name] < threshold: nextDelay = self.delay[name] self.delay[name] = min(self.delay[name] * 2, maxRestartDelay) else: nextDelay = 0 self.delay[name] = minRestartDelay if self.running and name in self._processes: self.restart[name] = self._reactor.callLater( nextDelay, self.startProcess, name ) elif shouldCleanup: # If this is a no-restart yes-cleanup process then remove it # on exit self.removeProcess(name) def _forceStopProcess(self, name, proc): self.emit("forceStop", self.getState(name)) super()._forceStopProcess(proc) def hasProcess(self, name): """ Check whether a process is defined with that name. """ return name in self.states def restartProcess(self, name): """ Manually restart a process, regardless of how it's been configured. """ self._setProcessState(name, LifecycleState.RESTARTING) self.emit("restartProcess", self.getState(name)) self._stopProcess(self, name) def stopProcess(self, name): """ Stop a process. """ self._setProcessState(name, LifecycleState.STOPPING) self.emit("stopProcess", self.getState(name)) self._stopProcess(name) def _stopProcess(self, name): self.assertRegistered(name) self._setProcessState(name, LifecycleState.STOPPING) proto = self.protocols.get(name, None) if proto is None: # If the proto isn't there then the process is definitely already # stopped. self._setProcessState(name, LifecycleState.STOPPED) else: # Same as procmon proc = proto.transport try: proc.signalProcess("TERM") except ProcessExitedAlready: pass else: self.murder[name] = self._reactor.callLater( self.killTime, self._forceStopProcess, name, proc ) def restartAll(self): """ Manually restart all processes, regardless of how they've been configured. """ for name in self._processes: self.restartProcess(name) def asdict(self): return dict( running=self.running, processes={name: self.getState(name) for name in self.states}, )
def main(ctx): log = create_logger(namespace="korbenware.cli.config") ctx.ensure_object(dict) ctx.obj["LOGGER"] = log
class MimeRegistry: log = create_logger() def __init__(self, config, applications): self.environment = config.mime.environment self.applications = applications self.lookup = dict() self.defaults = dict() for filename in XDG_MIMEINFO_CACHE_FILES: self.log.debug("Loading desktop mimeinfo database {filename}", filename=filename) database = DesktopDatabase.from_file(filename) if not database.parsed: self.log.warn( "INI file parse error while loading mimeinfo database {filename} - skipping!", # noqa filename=filename, log_failure=Failure(database.parse_exc), ) else: for mimetype, apps in database.items(): self.log.debug( "Associating applications {applications} with mimetype {mimetype}...", # noqa mimetype=mimetype, applications=apps, ) _insert(mimetype, apps, self.lookup) # TODO: Alternate algorithm that doesn't require reversing? # The list is short so it's not a big deal for mime_list in reversed( list(load_xdg_mime_lists(environment=self.environment))): if mime_list.parsed: added_associations = mime_list.get_added_associations() for mimetype, apps in added_associations.items(): self.log.debug( "Associating applications {applications} with mimetype {mimetype}...", # noqa mimetype=mimetype, applications=apps, ) _insert(mimetype, apps, self.lookup) removed_associations = mime_list.get_removed_associations() for mimetype, apps in removed_associations.items(): self.log.debug( "Disassociating applications {applications} from mimetype {mimetype}...", # noqa mimetype=mimetype, applications=apps, ) _remove(mimetype, apps, self.lookup) # Assumption is that if the mimetype is removed that we # don't want an associated default application either. # # I could change my mind on this. if mimetype in self.defaults: to_remove = { app for app in apps if app in self.defaults[mimetype] } if to_remove: self.log.debug( "Removing applications {applications} as defaults from mimetype {mimetype}...", # noqa mimetype=mimetype, applications=list(to_remove), ) for removed_app in to_remove: self.defaults[mimetype] = [ app for app in self.defaults[mimetype] if app is not removed_app ] default_applications = mime_list.get_default_applications() for mimetype, apps in default_applications.items(): self.log.debug( "Registering applications {applications} as the defaults for mimetype {mimetype}...", # noqa mimetype=mimetype, applications=apps, ) _insert(mimetype, apps, self.lookup) # Current assumption is that an override should override # the entire key. # # I could change my mind on this. self.defaults[mimetype] = apps else: self.log.warn( "Parse issues while loading mimeapp list at {filename} - skipping!", # noqa filename=mime_list.filename, log_failure=Failure(mime_list.parse_exc), ) def applications_by_filename(self, filename): return [ self.applications.entries[key] for key in self.lookup[get_type(filename)] if key in self.applications.entries ] def default_by_filename(self, filename): return [ self.applications.entries[key] for key in self.defaults[get_type(filename)] if key in self.applications.entries ]
try: f = open(filename, "r") except FileNotFoundError as e: raise NoConfigurationFoundError() from e with f: toml_config = toml.load(f) structured = cattr.structure(toml_config, BaseConfig) structured.meta.config_filename = filename return structured log = create_logger() def _log_config(path, obj, level): for attr_ in obj.__attrs_attrs__: if hasattr(attr_.type, "__attrs_attrs__"): _log_config(path + [attr_.name], getattr(obj, attr_.name), level) else: log.emit( level, "config: {path}={value}", path=".".join(path + [attr_.name]), value=getattr(obj, attr_.name), )
def main(ctx): config = ctx.config log = create_logger(namespace="korbenware.cli.ctl")