From 6c623e159bac959dd5d956e0bcfd2e3fdd72895f Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Fri, 29 May 2020 20:43:59 +1200 Subject: [PATCH] Add an output pager. Fixes #50. Requires pallets/click#1572. --- sno/diff_output.py | 162 +++++++++++++++++++++++---------------------- sno/output_util.py | 82 ++++++++++++++++++----- sno/show.py | 36 +++++----- 3 files changed, 165 insertions(+), 115 deletions(-) diff --git a/sno/diff_output.py b/sno/diff_output.py index d94f8322a..7f997736c 100644 --- a/sno/diff_output.py +++ b/sno/diff_output.py @@ -11,7 +11,10 @@ import click from . import gpkg -from .output_util import dump_json_output, resolve_output_path +from .output_util import ( + dump_json_output, + resolve_output_path, +) @contextlib.contextmanager @@ -50,82 +53,82 @@ def diff_output_text(*, output_path, **kwargs): In particular, geometry WKT is abbreviated and null values are represented by a unicode "␀" character. """ - fp = resolve_output_path(output_path) - pecho = {'file': fp, 'color': fp.isatty()} if isinstance(output_path, Path) and output_path.is_dir(): raise click.BadParameter( "Directory is not valid for --output with --text", param_hint="--output" ) def _out(dataset, diff): - path = dataset.path - pk_field = dataset.primary_key - prefix = f"{path}:" - repr_excl = [pk_field] - - for k, (v_old, v_new) in diff["META"].items(): - click.secho( - f"--- {prefix}meta/{k}\n+++ {prefix}meta/{k}", bold=True, **pecho - ) - - s_old = set(v_old.items()) - s_new = set(v_new.items()) - - diff_add = dict(s_new - s_old) - diff_del = dict(s_old - s_new) - all_keys = set(diff_del.keys()) | set(diff_add.keys()) - - for k in all_keys: - if k in diff_del: - click.secho( - text_row({k: diff_del[k]}, prefix="- ", exclude=repr_excl), - fg="red", - **pecho, - ) - if k in diff_add: - click.secho( - text_row({k: diff_add[k]}, prefix="+ ", exclude=repr_excl), - fg="green", - **pecho, - ) - - prefix = f"{path}:{pk_field}=" + with resolve_output_path(output_path) as fp: + pecho = {'file': fp, 'color': getattr(fp, 'color', fp.isatty())} + path = dataset.path + pk_field = dataset.primary_key + prefix = f"{path}:" + repr_excl = [pk_field] + + for k, (v_old, v_new) in diff["META"].items(): + click.secho( + f"--- {prefix}meta/{k}\n+++ {prefix}meta/{k}", bold=True, **pecho + ) - for k, v_old in diff["D"].items(): - click.secho(f"--- {prefix}{k}", bold=True, **pecho) - click.secho( - text_row(v_old, prefix="- ", exclude=repr_excl), fg="red", **pecho - ) + s_old = set(v_old.items()) + s_new = set(v_new.items()) + + diff_add = dict(s_new - s_old) + diff_del = dict(s_old - s_new) + all_keys = set(diff_del.keys()) | set(diff_add.keys()) + + for k in all_keys: + if k in diff_del: + click.secho( + text_row({k: diff_del[k]}, prefix="- ", exclude=repr_excl), + fg="red", + **pecho, + ) + if k in diff_add: + click.secho( + text_row({k: diff_add[k]}, prefix="+ ", exclude=repr_excl), + fg="green", + **pecho, + ) + + prefix = f"{path}:{pk_field}=" + + for k, v_old in diff["D"].items(): + click.secho(f"--- {prefix}{k}", bold=True, **pecho) + click.secho( + text_row(v_old, prefix="- ", exclude=repr_excl), fg="red", **pecho + ) - for o in diff["I"]: - click.secho(f"+++ {prefix}{o[pk_field]}", bold=True, **pecho) - click.secho( - text_row(o, prefix="+ ", exclude=repr_excl), fg="green", **pecho - ) + for o in diff["I"]: + click.secho(f"+++ {prefix}{o[pk_field]}", bold=True, **pecho) + click.secho( + text_row(o, prefix="+ ", exclude=repr_excl), fg="green", **pecho + ) - for _, (v_old, v_new) in diff["U"].items(): - click.secho( - f"--- {prefix}{v_old[pk_field]}\n+++ {prefix}{v_new[pk_field]}", - bold=True, - **pecho, - ) + for _, (v_old, v_new) in diff["U"].items(): + click.secho( + f"--- {prefix}{v_old[pk_field]}\n+++ {prefix}{v_new[pk_field]}", + bold=True, + **pecho, + ) - s_old = set(v_old.items()) - s_new = set(v_new.items()) + s_old = set(v_old.items()) + s_new = set(v_new.items()) - diff_add = dict(s_new - s_old) - diff_del = dict(s_old - s_new) - all_keys = sorted(set(diff_del.keys()) | set(diff_add.keys())) + diff_add = dict(s_new - s_old) + diff_del = dict(s_old - s_new) + all_keys = sorted(set(diff_del.keys()) | set(diff_add.keys())) - for k in all_keys: - if k in diff_del: - rk = text_row({k: diff_del[k]}, prefix="- ", exclude=repr_excl) - if rk: - click.secho(rk, fg="red", **pecho) - if k in diff_add: - rk = text_row({k: diff_add[k]}, prefix="+ ", exclude=repr_excl) - if rk: - click.secho(rk, fg="green", **pecho) + for k in all_keys: + if k in diff_del: + rk = text_row({k: diff_del[k]}, prefix="- ", exclude=repr_excl) + if rk: + click.secho(rk, fg="red", **pecho) + if k in diff_add: + rk = text_row({k: diff_add[k]}, prefix="+ ", exclude=repr_excl) + if rk: + click.secho(rk, fg="green", **pecho) yield _out @@ -367,20 +370,19 @@ def diff_output_html(*, output_path, repo, base, target, dataset_count, **kwargs if not output_path: output_path = Path(repo.path) / "DIFF.html" - fo = resolve_output_path(output_path) - - # Read all the geojson back in, and stick them in a dict - all_datasets_geojson = {} - for filename in os.listdir(tempdir): - with open(tempdir / filename) as json_file: - all_datasets_geojson[os.path.splitext(filename)[0]] = json.load( - json_file + with resolve_output_path(output_path) as fo: + # Read all the geojson back in, and stick them in a dict + all_datasets_geojson = {} + for filename in os.listdir(tempdir): + with open(tempdir / filename) as json_file: + all_datasets_geojson[os.path.splitext(filename)[0]] = json.load( + json_file + ) + fo.write( + template.substitute( + {"title": title, "geojson_data": json.dumps(all_datasets_geojson)} ) - fo.write( - template.substitute( - {"title": title, "geojson_data": json.dumps(all_datasets_geojson)} ) - ) - if fo != sys.stdout: - fo.close() - webbrowser.open_new(f"file://{output_path.resolve()}") + if fo != sys.stdout: + fo.close() + webbrowser.open_new(f"file://{output_path.resolve()}") diff --git a/sno/output_util.py b/sno/output_util.py index b269a5a91..b6c099fd0 100644 --- a/sno/output_util.py +++ b/sno/output_util.py @@ -1,6 +1,13 @@ import io import json +import os +import shutil import sys +import threading +from contextlib import closing, contextmanager +from queue import Queue, Empty + +import click JSON_PARAMS = { "compact": {}, @@ -14,35 +21,54 @@ def dump_json_output(output, output_path, json_style="pretty"): Dumps the output to JSON in the output file. """ - fp = resolve_output_path(output_path) - - if json_style == 'pretty' and fp == sys.stdout and fp.isatty(): - # Add syntax highlighting - from pygments import highlight - from pygments.lexers import JsonLexer - from pygments.formatters import TerminalFormatter + with resolve_output_path(output_path) as fp: + if json_style == 'pretty' and getattr(fp, 'color', fp.isatty()): + # Add syntax highlighting + from pygments import highlight + from pygments.lexers import JsonLexer + from pygments.formatters import TerminalFormatter - dumped = json.dumps(output, **JSON_PARAMS[json_style]) - highlighted = highlight(dumped.encode(), JsonLexer(), TerminalFormatter()) - fp.write(highlighted) - else: - json.dump(output, fp, **JSON_PARAMS[json_style]) + dumped = json.dumps(output, **JSON_PARAMS[json_style]) + highlighted = highlight(dumped.encode(), JsonLexer(), TerminalFormatter()) + fp.write(highlighted) + else: + json.dump(output, fp, **JSON_PARAMS[json_style]) -def resolve_output_path(output_path): +@contextmanager +def resolve_output_path(output_path, allow_pager=True): """ - Takes a path-ish thing, and returns the appropriate writable file-like object. + Context manager. + + Takes a path-ish thing, and yields the appropriate writable file-like object. The path-ish thing could be: * a pathlib.Path object * a file-like object * the string '-' or None (both will return sys.stdout) + + If the file is not stdout, it will be closed when exiting the context manager. + + If allow_pager=True (the default) and the file is stdout, this will attempt to use a + pager to display long output. """ + if isinstance(output_path, io.IOBase): - return output_path + # Make this contextmanager re-entrant + yield output_path elif (not output_path) or output_path == "-": - return sys.stdout + if allow_pager and get_input_mode() == InputMode.INTERACTIVE: + pager_cmd = ( + os.environ.get('SNO_PAGER') or os.environ.get('PAGER') or DEFAULT_PAGER + ) + + with _push_environment('PAGER', pager_cmd): + with click.get_pager_file(color=True) as pager: + yield pager + else: + yield sys.stdout else: - return output_path.open("w") + with closing(output_path.open("w")) as f: + yield f class InputMode: @@ -69,3 +95,25 @@ def is_empty_stream(stream): return True stream.seek(pos) return False + + +def _setenv(k, v): + if v is None: + del os.environ[k] + else: + os.environ[k] = v + + +@contextmanager +def _push_environment(k, v): + orig = os.environ.get(k) + _setenv(k, v) + try: + yield + finally: + _setenv(k, orig) + + +DEFAULT_PAGER = shutil.which('less') +if DEFAULT_PAGER: + DEFAULT_PAGER += ' --quit-if-one-screen --no-init -R' diff --git a/sno/show.py b/sno/show.py index a428621b0..eafd84ef9 100644 --- a/sno/show.py +++ b/sno/show.py @@ -92,24 +92,24 @@ def patch_output_text(*, target, output_path, **kwargs): by a unicode "␀" character. """ commit = target.head_commit - fp = resolve_output_path(output_path) - pecho = {'file': fp, 'color': fp.isatty()} - with diff.diff_output_text(output_path=fp, **kwargs) as diff_writer: - author = commit.author - author_time_utc = datetime.fromtimestamp(author.time, timezone.utc) - author_timezone = timezone(timedelta(minutes=author.offset)) - author_time_in_author_timezone = author_time_utc.astimezone(author_timezone) - - click.secho(f'commit {commit.hex}', fg='yellow') - click.secho(f'Author: {author.name} <{author.email}>', **pecho) - click.secho( - f'Date: {author_time_in_author_timezone.strftime("%c %z")}', **pecho - ) - click.secho(**pecho) - for line in commit.message.splitlines(): - click.secho(f' {line}', **pecho) - click.secho(**pecho) - yield diff_writer + with resolve_output_path(output_path) as fp: + pecho = {'file': fp, 'color': getattr(fp, 'color', fp.isatty())} + with diff.diff_output_text(output_path=fp, **kwargs) as diff_writer: + author = commit.author + author_time_utc = datetime.fromtimestamp(author.time, timezone.utc) + author_timezone = timezone(timedelta(minutes=author.offset)) + author_time_in_author_timezone = author_time_utc.astimezone(author_timezone) + + click.secho(f'commit {commit.hex}', fg='yellow', **pecho) + click.secho(f'Author: {author.name} <{author.email}>', **pecho) + click.secho( + f'Date: {author_time_in_author_timezone.strftime("%c %z")}', **pecho + ) + click.secho(**pecho) + for line in commit.message.splitlines(): + click.secho(f' {line}', **pecho) + click.secho(**pecho) + yield diff_writer @contextlib.contextmanager