forked from jarun/buku
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request jarun#785 from LeXofLeviafan/bukuserver-runner
added a bukuserver runner tool
- Loading branch information
Showing
4 changed files
with
326 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# Bukuserver runner | ||
|
||
This tool can be used to run and restart Bukuserver, switching databases between runs. It has no third-party dependencies, allowing to run Bukuserver sandboxed in a virtualenv as easily as the system-wide install (which is especifally useful for development). | ||
|
||
I suggest installing/symlinking it system-wide (e.g. as an `/usr/local/bin/buku-server` executable). Either of the `*.desktop` files can be edited according to match your setup and installed in your `local/share/applications/` folder for access from system menu. | ||
|
||
On Windows, you can create a shortcut file pointing to any Python executable (`python.exe` for windowed mode, `pythonw.exe` for headless) with added CLI arguments: path to `buku-server.py` followed by `--stop-if-running`. | ||
|
||
Note that windowed mode may be necessary if you want to see Bukuserver logs, or use noGUI mode (see below). The terminal window can be minimized to tray when not in use, by a program like [KDocker](https://github.com/user-none/KDocker), [RBTray](https://github.com/benbuck/rbtray) or [SmartSystemMenu](https://github.com/AlexanderPro/SmartSystemMenu). | ||
|
||
## Usage | ||
|
||
When running `buku-server.py` without arguments, it will prompt for database file, then start the Bukuserver. These actions will be repeated once Bukuserver stops running (e.g. after hitting `Ctrl+C`). The script will quit if you cancel the prompt. | ||
|
||
In GUI mode, the prompt is implemented as 2 dialogs; a list of databases to choose from, and a text input for creating a new DB. In the shell mode, you can type in DB number from the list, or a new DB name. Note that DB names must be valid filenames in your system (sans the `.db` extension). These files are located in your Buku settings folder (along with the default `bookmarks.db` file). | ||
|
||
Running `buku-server.py --stop` will kill the currently running Bukuserver process (thus allowing to restart it in the background, like a daemon). `buku-server.py --stop-if-running` will either start the script or kill Bukuserver if it's running already. | ||
|
||
## Environment variables | ||
|
||
The script behaviour can be configured by setting the following environment variables: | ||
* `BUKUSERVER` specifies path to your Bukuserver executable or Buku source directory. | ||
* `BUKU_DEVMODE` – if not empty, Bukuserver will be run in development mode. Normally used with source directory in `BUKUSERVER`. | ||
* `BUKU_VENV` overrides path to your virtualenv sandbox (default depends on whether `BUKU_DEVMODE` is set): | ||
- when devmode is off, the virtualenv location defaults to a `venv/` folder in your Buku settings directory; | ||
- when devmode is on, the virtualenv location defaults to a `venv/` folder in the source directory. | ||
* `BUKU_NOGUI` – if not empty, fallback shell prompt will be used (also happens if Tkinter is not present in your Python installation). | ||
|
||
Default values for all of these (as well as for `BUKUSERVER_` options) can be specified in a `bukuserver.env` file in your Buku settings folder: | ||
```sh | ||
# ~/.local/share/buku/bukuserver.env | ||
BUKUSERVER='~/Sources/buku/' # when running from sources | ||
BUKUSERVER_THEME=slate | ||
BUKUSERVER_DISABLE_FAVICON=false | ||
BUKUSERVER_OPEN_IN_NEW_TAB=true | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# This file can be used as a windowless startup/restart shortcut on Linux desktop/menu (after editing the Exec= command line) | ||
[Desktop Entry] | ||
Icon=bookmark-add | ||
Name=Bukuserver (headless) | ||
GenericName=Bookmaks manager | ||
Comment=WebUI for the buku bookmarks manager | ||
Categories=Utility;Network; | ||
Exec={BUKU_SERVER} --stop-if-running | ||
#Exec=/usr/local/bin/buku-server --stop-if-running |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# This file can be used as a windowed startup/restart shortcut on Linux desktop/menu (after editing the Exec= command line) | ||
[Desktop Entry] | ||
Icon=bookmark-add | ||
Name=Bukuserver | ||
GenericName=Bookmaks manager | ||
Comment=WebUI for the buku bookmarks manager | ||
Categories=Utility;Network; | ||
Exec={TERMINAL} -e '{BUKU_SERVER} --stop-if-running' | ||
#Exec=xfce4-terminal -e '/usr/local/bin/buku-server --stop-if-running' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,272 @@ | ||
#!/usr/bin/env python | ||
# Usage: `buku-server.py` starts up the server, `buku-server.py --stop` sends TERM to the already running server | ||
# `buku-server.py --stop-if-running` will either start the script or kill Bukuserver if it's running already | ||
from signal import signal, SIGINT | ||
from contextlib import contextmanager | ||
from os import environ as env | ||
import sys | ||
import os | ||
import re | ||
import shlex | ||
import csv | ||
import venv | ||
import subprocess | ||
|
||
TITLE = re.sub(r'\.py$', '', os.path.basename(__file__)) | ||
IS_WINDOWS = sys.platform == 'win32' | ||
|
||
is_path = lambda s: ('/' in s or os.sep in s or s in ('.', '..')) | ||
in_venv = lambda virtualenv, name: os.path.join(virtualenv, ('Scripts' if IS_WINDOWS else 'bin'), name) | ||
set_title = lambda s: (print(f'\033]2;{s}\007', end='') if not IS_WINDOWS else run(f'title {s}', shell=True)) | ||
|
||
try: | ||
from tkinter.messagebox import showerror, askyesno | ||
from tkinter.simpledialog import askstring, Dialog | ||
from tkinter import ttk | ||
import tkinter as tk | ||
|
||
class QueryList(Dialog): | ||
def __init__(self, title, prompt, values, initial=None, parent=None): | ||
self._prompt, self._vals, self._initial = prompt, values, (initial if initial in values else values[0]) | ||
super().__init__(parent, title) | ||
|
||
def body(self, master): | ||
w = ttk.Label(master, text=self._prompt, justify=tk.LEFT) | ||
w.grid(row=0, padx=5, sticky=tk.W) | ||
self._list = ttk.Treeview(master, show='tree') | ||
self._list.grid(row=1, padx=5, sticky=tk.W+tk.E) | ||
scroll = ttk.Scrollbar(master) | ||
scroll.grid(row=1, padx=5, sticky=tk.E+tk.N+tk.S) | ||
self._list.config(yscrollcommand=scroll.set) | ||
scroll.config(command=self._list.yview) | ||
self._keys = [self._list.insert('', 'end', text=s) for s in self._vals] | ||
self.select(self._vals.index(self._initial)) | ||
self._list.bind('<KeyPress>', self.onkeypress) | ||
self._list.bind('<Double-1>', lambda _: self.after('idle', self.ok)) | ||
return self._list | ||
|
||
def select(self, index): | ||
self._list.see(self._keys[index]) | ||
self._list.focus(self._keys[index]) | ||
self._list.selection_set(self._keys[index]) | ||
|
||
def onkeypress(self, evt): | ||
match = [i for i, s in enumerate(self._vals) if s.startswith(evt.char)] | ||
if evt.char and match: | ||
cur = self._list.index(self._list.focus()) | ||
self.select(cur+1 if self._vals[cur].startswith(evt.char) and cur+1 in match else match[0]) | ||
|
||
def validate(self): | ||
self.result = self._list.item(self._list.focus())['text'] | ||
return True | ||
|
||
asklist = lambda title, prompt, values, initial=None: QueryList(title, prompt, values, initial=initial).result | ||
GUI = True | ||
except ImportError: | ||
GUI = False | ||
print('Failed to initialize GUI', file=sys.stderr) | ||
|
||
def is_valid_filepath(path): | ||
try: | ||
os.lstat(path) | ||
except FileNotFoundError: | ||
pass | ||
except Exception: | ||
return False | ||
if os.path.isdir(path) or path.endswith(os.path.sep): | ||
return False # directory | ||
drive, s = os.path.splitdrive(path) | ||
return not IS_WINDOWS or not re.search(r'[<>:"|?*]', s) | ||
|
||
@contextmanager | ||
def ignore_interrupt(): # temporarily disables raising KeyboardInterrupt on Ctrl+C | ||
old_handler = signal(SIGINT, lambda sig, frame: None) | ||
yield | ||
signal(SIGINT, old_handler) | ||
|
||
def run(command, *, shell=IS_WINDOWS, check=True): | ||
try: | ||
command = (os.path.expandvars(command) if isinstance(command, str) else [os.path.expandvars(s) for s in command]) | ||
with ignore_interrupt(): | ||
return subprocess.run(command, shell=shell, check=check) | ||
except Exception as e: | ||
print(e, file=sys.stderr) | ||
sys.exit(1) | ||
|
||
def parse_csv(text): | ||
text = (text.decode() if isinstance(text, bytes) else str(text)).strip() | ||
keys, lines = None, re.split(r'[\r\n]+', text) | ||
for row in csv.reader(lines): | ||
if not keys: | ||
keys = row | ||
else: | ||
yield dict(zip(keys, row)) | ||
|
||
def find_process(query, regex): | ||
if IS_WINDOWS: | ||
query = str(query or "name LIKE '%'") | ||
command = ['wmic', 'process', 'where', query, 'get', 'commandline,processid', '/format:csv'] | ||
output = subprocess.check_output(command, shell=True) | ||
for process in parse_csv(output): | ||
if re.search(regex, process['CommandLine']): | ||
return int(process['ProcessId']) | ||
else: | ||
command = 'ps x -o pid,cmd | awk ' + shlex.quote(f'$2 ~ /{query or ".*"}/') | ||
output = subprocess.check_output(command, shell=True) | ||
for line in output.decode().splitlines(): | ||
pid, cmdline = line.lstrip().split(' ', 1) | ||
if re.search(regex, cmdline): | ||
return pid | ||
return None # nothing found | ||
|
||
def find_bukuserver_process(): | ||
if not IS_WINDOWS: | ||
return find_process(r'(^|\/)python[.0-9]*$', r'\b(bukuserver(/server\.py)?) run$') | ||
return find_process('name like "python%.exe"', r'\b(bukuserver-script\.py|bukuserver([/\\]server\.py)?)"? run$') | ||
|
||
def kill_process(pid): | ||
run(['kill', str(pid)] if not IS_WINDOWS else ['taskkill', '/F', '/pid', str(pid)]) | ||
|
||
def get_buku_config_dir(): | ||
path = (env.get('APPDATA') if IS_WINDOWS else | ||
env.get('XDG_DATA_HOME') or os.path.join(os.path.expanduser('~'), '.local', 'share')) | ||
return os.path.join(path, 'buku') | ||
|
||
def read_env_file(path): | ||
regex = re.compile('([_A-Z]+)=(?:"(.*)"|\'(.*)\'|(.*))') | ||
try: | ||
with open(path, encoding='utf-8') as fin: | ||
for line in fin: | ||
tokens = shlex.split(line, comments=True) | ||
if tokens and (m := regex.fullmatch(tokens[0])): | ||
yield (m[1], m[2] or m[3] or m[4]) | ||
except FileNotFoundError: | ||
pass | ||
|
||
|
||
def selectdb(confdir, old=None, gui=GUI, title=TITLE): | ||
old = old and re.sub(r'^' + re.escape(confdir+os.path.sep), '', re.sub(r'\.db$', '', old)) | ||
if old and any(c in old for c in ['/', os.path.sep]): | ||
old = None | ||
dbs = sorted(s[:-3] for s in os.listdir(confdir) if s.lower().endswith('.db')) | ||
if gui: | ||
db = (None if not dbs else | ||
asklist(title, 'Choose DB (or click Cancel to create new DB)', dbs, initial=old or 'bookmarks')) | ||
while not db: | ||
db = askstring(title, f'{"Create new DB?":65}', initialvalue=old or 'bookmarks') | ||
if db is None: | ||
print('No name given, qutting', file=sys.stderr) | ||
return None | ||
dbfile = os.path.join(confdir, db+'.db') | ||
if not db or any(c in db for c in ['/', os.path.sep]) or not is_valid_filepath(dbfile): | ||
showerror(title, f'Invalid DB name: "{db}"') | ||
db = None | ||
elif os.path.exists(dbfile): | ||
if not askyesno(title, f'"{db}" exists already. Open anyway?'): | ||
db = None | ||
else: | ||
db = None | ||
while not db: | ||
try: | ||
print('\nType DB name or index (0 to quit):') | ||
for idx, name in enumerate(dbs, start=1): | ||
print(f'{idx}. {name}') | ||
try: | ||
db = input('> ' if not old else f'> [{old}] ').strip() or old or 'bookmarks' | ||
except EOFError as e: | ||
raise KeyboardInterrupt from e | ||
except KeyboardInterrupt: | ||
with ignore_interrupt(): | ||
print() | ||
print('Input cancelled', file=sys.stderr) | ||
return None | ||
try: | ||
idx = int(db) | ||
if idx == 0: | ||
print('Entered "0", quitting', file=sys.stderr) | ||
return None | ||
if idx > 0: | ||
db = dbs[idx-1] | ||
break | ||
except IndexError: | ||
print('No such index!', file=sys.stderr) | ||
db = None | ||
continue | ||
except ValueError: | ||
pass # not an index | ||
dbfile = os.path.join(confdir, db+'.db') | ||
if not db or any(c in db for c in ['/', os.path.sep]) or not is_valid_filepath(dbfile): | ||
print(f'Invalid DB name: "{db}"', file=sys.stderr) | ||
db = None | ||
elif not os.path.exists(dbfile): | ||
if input(f'"{db}" does not exist yet. Create? [Y/n] ').upper().strip() == 'N': | ||
db = None | ||
return db and os.path.join(confdir, db+'.db') | ||
|
||
def load_virtualenv(virtualenv, devmode=False, reinstall=False): | ||
print(f'Using {os.path.abspath(virtualenv)}') | ||
venv.create(virtualenv, with_pip=True, prompt='buku') | ||
run([in_venv(virtualenv, 'python'), '-m', 'pip', 'install', '--upgrade', 'pip']) | ||
if reinstall: | ||
env.get('BUKUSERVER_LOCALE') and run([in_venv(virtualenv, 'pip'), 'install', 'flask-babel']) | ||
if not devmode: | ||
run([in_venv(virtualenv, 'pip'), 'install', '.[server]']) | ||
else: | ||
run([in_venv(virtualenv, 'pip'), 'install', '--editable', '.[server]']) | ||
|
||
def prepare_vars(): | ||
confdir = get_buku_config_dir() | ||
for name, value in read_env_file(os.path.join(confdir, 'bukuserver.env')): | ||
print(f'default:{name}={shlex.quote(value)}') | ||
env.setdefault(name, value) | ||
workdir, exec, devmode = None, env.get('BUKUSERVER') or '', bool(env.get('BUKU_DEVMODE')) | ||
devmode and env.setdefault('BUKUSERVER_DEBUG', 'true') | ||
if exec and os.path.isdir(os.path.expanduser(exec)): | ||
workdir, exec = exec, os.path.join('bukuserver', 'server.py') | ||
return { | ||
'confdir': confdir, | ||
'devmode': devmode, | ||
'gui': GUI and not env.get('BUKU_NOGUI'), | ||
'exec': exec or 'bukuserver', | ||
'workdir': workdir, | ||
'virtualenv': env.get('BUKU_VENV') or (workdir and os.path.join(('.' if devmode else confdir), 'venv')), | ||
} | ||
|
||
def run_repeatedly(confdir, devmode=False, gui=GUI, exec=None, workdir=None, virtualenv=None): | ||
if workdir: | ||
os.chdir(os.path.expanduser(workdir)) | ||
elif virtualenv: | ||
os.chdir(os.path.expanduser(virtualenv)) | ||
virtualenv = '.' | ||
set_title(TITLE) | ||
virtualenv and load_virtualenv(virtualenv, devmode=devmode, reinstall=bool(workdir)) | ||
command = [os.path.expanduser(exec), 'run'] | ||
if exec.endswith('.py'): | ||
command = ['python'] + command | ||
elif exec == 'bukuserver': | ||
command = ['python', '-m'] + command | ||
if virtualenv: | ||
command[0] = in_venv(virtualenv, command[0]) | ||
set_title(f'{TITLE} [{shlex.join(command)}]') | ||
db = env.get('BUKUSERVER_DB_FILE') or os.path.join(confdir, 'bookmarks.db') | ||
while True: | ||
print('Running Bukuserver…') | ||
if not (db := selectdb(confdir, gui=gui, old=db)): | ||
break | ||
env['BUKUSERVER_DB_FILE'] = db | ||
print(f'BUKUSERVER_DB_FILE={db}') | ||
run(command, check=False) | ||
|
||
if __name__ == '__main__': | ||
if (pid := find_bukuserver_process()): | ||
if any(s in sys.argv for s in ['--stop', '--stop-if-running']): | ||
print(f'Killing process {pid}') | ||
kill_process(pid) | ||
sys.exit() | ||
else: | ||
print('Already running bukuserver!', file=sys.stderr) | ||
sys.exit(1) | ||
if '--stop' in sys.argv: | ||
print('Could not find a running bukuserver process!', file=sys.stderr) | ||
sys.exit(1) | ||
run_repeatedly(**prepare_vars()) |