From ef2a0f2d18fd4cb061d6b76e05b290de4c231a83 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:08:02 +0100 Subject: [PATCH 01/65] Move Docker installation option lower - it's not very useful anyway --- README.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 10e1c96..7cd8b69 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,28 @@ asciinema is included in repositories of most popular package managers on Mac OS X, Linux and FreeBSD. Look for package named `asciinema`. See the [list of available packages](https://asciinema.org/docs/installation). +### Running latest version from source code checkout + +If you can't use Python package or native package for your OS is outdated you +can clone the repo and run asciinema straight from the checkout. + +Clone the repo: + + git clone https://github.com/asciinema/asciinema.git + cd asciinema + +If you want latest stable version: + + git checkout master + +If you want current development version: + + git checkout develop + +Then run it with: + + python3 -m asciinema --version + ### Docker image asciinema Docker image is based on Ubuntu 16.04 and has the latest version of @@ -97,28 +119,6 @@ as the command, install extra packages and manually start `asciinema rec`: root@6689517d99a1:~# apt-get install foobar root@6689517d99a1:~# asciinema rec -### Running latest version from source code checkout - -If none of the above works for you just clone the repo and run asciinema -straight from the checkout. - -Clone the repo: - - git clone https://github.com/asciinema/asciinema.git - cd asciinema - -If you want latest stable version: - - git checkout master - -If you want current development version: - - git checkout develop - -Then run it with: - - python3 -m asciinema --version - ## Usage asciinema is composed of multiple commands, similar to `git`, `apt-get` or From 774a18d36b8e964aa51d466aa6d8ce672eb16c75 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:12:30 +0100 Subject: [PATCH 02/65] Fix Docker image build --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0528a1b..2a988bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,9 @@ RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 RUN mkdir /usr/src/app COPY setup.cfg /usr/src/app COPY setup.py /usr/src/app -COPY README.md /usr/src/app +COPY *.md /usr/src/app/ +COPY doc/*.md /usr/src/app/doc/ +COPY man/asciinema.1 /usr/src/app/man/ COPY asciinema /usr/src/app/asciinema WORKDIR /usr/src/app RUN python3 setup.py install From cc7391093309e9ea49c4bf91d659b17225e8deb0 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:32:48 +0100 Subject: [PATCH 03/65] Upgrade Docker image to Ubuntu 18.04 --- Dockerfile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a988bf..b4e9ea2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:16.04 +FROM ubuntu:18.04 RUN apt-get update && apt-get install -y \ ca-certificates \ diff --git a/README.md b/README.md index 7cd8b69..5ce43c0 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Then run it with: ### Docker image -asciinema Docker image is based on Ubuntu 16.04 and has the latest version of +asciinema Docker image is based on Ubuntu 18.04 and has the latest version of asciinema recorder pre-installed. docker pull asciinema/asciinema From 4633c87c77922ec5903965306ecff45531b72092 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:33:08 +0100 Subject: [PATCH 04/65] Use asciinema binary as entrypoint in Docker container --- Dockerfile | 2 +- README.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b4e9ea2..0c037a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,4 @@ ENV LANG en_US.utf8 ENV SHELL /bin/bash ENV USER docker WORKDIR /root -CMD ["asciinema", "rec"] +ENTRYPOINT ["/usr/local/bin/asciinema"] diff --git a/README.md b/README.md index 5ce43c0..8a1c58e 100644 --- a/README.md +++ b/README.md @@ -105,17 +105,19 @@ asciinema recorder pre-installed. When running it don't forget to allocate a pseudo-TTY (`-t`), keep STDIN open (`-i`) and mount config directory volume (`-v`): - docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema + docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema rec -Default command run in a container is `asciinema rec`. +Container's entrypoint is set to `/usr/local/bin/asciinema` so you can run the +container with any arguments you would normally pass to `asciinema` binary (see +Usage section for commands and options). There's not much software installed in this image though. In most cases you may want to install extra programs before recording. One option is to derive new image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash` -as the command, install extra packages and manually start `asciinema rec`: +as the entrypoint, install extra packages and manually start `asciinema rec`: - docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema /bin/bash + docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema --entrypoint=/bin/bash asciinema/asciinema root@6689517d99a1:~# apt-get install foobar root@6689517d99a1:~# asciinema rec From 21dd13d4152c16c0141f203df1e338b596d63b7e Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:37:02 +0100 Subject: [PATCH 05/65] Remove extra whitespace --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a1c58e..bff7320 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash` as the entrypoint, install extra packages and manually start `asciinema rec`: - docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema --entrypoint=/bin/bash asciinema/asciinema + docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema --entrypoint=/bin/bash asciinema/asciinema root@6689517d99a1:~# apt-get install foobar root@6689517d99a1:~# asciinema rec From d56f275a1badddcf2898c3672883157138ca2489 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 12 Jan 2019 20:38:12 +0100 Subject: [PATCH 06/65] Update year in copyright notice --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bff7320..22e1367 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,6 @@ source [contributors](https://github.com/asciinema/asciinema/contributors). ## License -Copyright © 2011–2018 Marcin Kulik. +Copyright © 2011–2019 Marcin Kulik. All code is licensed under the GPL, v3 or later. See LICENSE file for details. From 8aecc1d55824b50cdc9c932436ee5a9b6311d5c6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 12:26:20 +0100 Subject: [PATCH 07/65] Let pty recorder report elapsed time --- asciinema/asciicast/raw.py | 4 ++-- asciinema/asciicast/v2.py | 9 ++++----- asciinema/pty.py | 8 ++++++-- tests/pty_test.py | 4 ++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index e68db97..ff154bc 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -32,8 +32,8 @@ class writer(): self.queue.put(None) self.process.join() - def write_stdin(self, data): + def write_stdin(self, ts, data): pass - def write_stdout(self, data): + def write_stdout(self, ts, data): self.queue.put(data) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 34eb8cb..2e74da2 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -176,17 +176,16 @@ class async_writer(): args=(self.path, header, mode, self.queue) ) self.process.start() - self.start_time = time.time() - self.time_offset return self def __exit__(self, exc_type, exc_value, exc_traceback): self.queue.put(None) self.process.join() - def write_stdin(self, data): - ts = time.time() - self.start_time + def write_stdin(self, ts, data): + ts = ts + self.time_offset self.queue.put([ts, 'i', data]) - def write_stdout(self, data): - ts = time.time() - self.start_time + def write_stdout(self, ts, data): + ts = ts + self.time_offset self.queue.put([ts, 'o', data]) diff --git a/asciinema/pty.py b/asciinema/pty.py index 9e731cc..6abdb5d 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -10,12 +10,14 @@ import signal import struct import sys import termios +import time from asciinema.term import raw def record(command, writer, env=os.environ, rec_stdin=False): master_fd = None + start_time = None def _set_pty_size(): ''' @@ -40,7 +42,7 @@ def record(command, writer, env=os.environ, rec_stdin=False): def _handle_master_read(data): '''Handles new data on child process stdout.''' - writer.write_stdout(data) + writer.write_stdout(time.time() - start_time, data) _write_stdout(data) def _write_master(data): @@ -56,7 +58,7 @@ def record(command, writer, env=os.environ, rec_stdin=False): _write_master(data) if rec_stdin: - writer.write_stdin(data) + writer.write_stdin(time.time() - start_time, data) def _signals(signal_list): old_handlers = [] @@ -129,6 +131,8 @@ def record(command, writer, env=os.environ, rec_stdin=False): _set_pty_size() + start_time = time.time() + with raw(pty.STDIN_FILENO): try: _copy(pipe_r) diff --git a/tests/pty_test.py b/tests/pty_test.py index 2b1514f..8f42c14 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -12,10 +12,10 @@ class FakeStdout: def __init__(self): self.data = [] - def write_stdout(self, data): + def write_stdout(self, ts, data): self.data.append(data) - def write_stdin(self, data): + def write_stdin(self, ts, data): pass From 13bb071d85247430f9f28fe99b3729f3c18a7d7e Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 13:15:39 +0100 Subject: [PATCH 08/65] Pass `append` instead of mode to all writer types --- asciinema/asciicast/v2.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 2e74da2..45c3ab5 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -94,20 +94,21 @@ def build_header(metadata): class writer(): - def __init__(self, path, width=None, height=None, header=None, mode='w', buffering=-1): + def __init__(self, path, width=None, height=None, header=None, append=False, buffering=-1): self.path = path - self.mode = mode self.buffering = buffering self.stdin_decoder = codecs.getincrementaldecoder('UTF-8')('replace') self.stdout_decoder = codecs.getincrementaldecoder('UTF-8')('replace') - if mode == 'w': + if append: + self.mode = 'a' + self.header = None + else: + self.mode = 'w' self.header = {'version': 2, 'width': width, 'height': height} self.header.update(header or {}) assert type(self.header['width']) == int, 'width or header missing' assert type(self.header['height']) == int, 'height or header missing' - else: - self.header = None def __enter__(self): self.file = open(self.path, mode=self.mode, buffering=self.buffering) @@ -150,8 +151,8 @@ class writer(): self.file.write(line + '\n') -def write_json_lines_from_queue(path, header, mode, queue): - with writer(path, header=header, mode=mode, buffering=1) as w: +def write_json_lines_from_queue(path, header, append, queue): + with writer(path, header=header, append=append, buffering=1) as w: for event in iter(queue.get, None): w.write_event(event) @@ -170,10 +171,9 @@ class async_writer(): def __enter__(self): header = build_header(self.metadata) - mode = 'a' if self.append else 'w' self.process = Process( target=write_json_lines_from_queue, - args=(self.path, header, mode, self.queue) + args=(self.path, header, self.append, self.queue) ) self.process.start() return self From 8e29f975d2032b236978c6adfa79a403b203655d Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 13:20:17 +0100 Subject: [PATCH 09/65] Don't overwrite buffering mode in async writer level --- asciinema/asciicast/v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 45c3ab5..b82b911 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -94,7 +94,7 @@ def build_header(metadata): class writer(): - def __init__(self, path, width=None, height=None, header=None, append=False, buffering=-1): + def __init__(self, path, width=None, height=None, header=None, append=False, buffering=1): self.path = path self.buffering = buffering self.stdin_decoder = codecs.getincrementaldecoder('UTF-8')('replace') @@ -152,7 +152,7 @@ class writer(): def write_json_lines_from_queue(path, header, append, queue): - with writer(path, header=header, append=append, buffering=1) as w: + with writer(path, header=header, append=append) as w: for event in iter(queue.get, None): w.write_event(event) From 2d27db20dcf8a18a0635e1ec794daf856bdb6c02 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 13:57:45 +0100 Subject: [PATCH 10/65] Refactor v2 writer --- asciinema/asciicast/v2.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index b82b911..bddb419 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -121,30 +121,20 @@ class writer(): def __exit__(self, exc_type, exc_value, exc_traceback): self.file.close() - def write_event(self, ts, etype=None, data=None): - if etype is None: - ts, etype, data = ts - - ts = round(ts, 6) - - if etype == 'o': - if type(data) == str: - data = data.encode(encoding='utf-8', errors='strict') - text = self.stdout_decoder.decode(data) - self.__write_line([ts, etype, text]) - elif etype == 'i': - if type(data) == str: - data = data.encode(encoding='utf-8', errors='strict') - text = self.stdin_decoder.decode(data) - self.__write_line([ts, etype, text]) - else: - self.__write_line([ts, etype, data]) - def write_stdout(self, ts, data): - self.write_event(ts, 'o', data) + if type(data) == str: + data = data.encode(encoding='utf-8', errors='strict') + data = self.stdout_decoder.decode(data) + self.__write_event(ts, 'o', data) def write_stdin(self, ts, data): - self.write_event(ts, 'i', data) + if type(data) == str: + data = data.encode(encoding='utf-8', errors='strict') + data = self.stdin_decoder.decode(data) + self.__write_event(ts, 'i', data) + + def __write_event(self, ts, etype, data): + self.__write_line([round(ts, 6), etype, data]) def __write_line(self, obj): line = json.dumps(obj, ensure_ascii=False, indent=None, separators=(', ', ': ')) @@ -154,7 +144,12 @@ class writer(): def write_json_lines_from_queue(path, header, append, queue): with writer(path, header=header, append=append) as w: for event in iter(queue.get, None): - w.write_event(event) + ts, etype, data = event + + if etype == 'o': + w.write_stdout(ts, data) + elif etype == 'i': + w.write_stdin(ts, data) class async_writer(): From b9ece310a36dfe62680bde5223396f4b8b1a6168 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 14:10:40 +0100 Subject: [PATCH 11/65] Move recording details to new `recorder` module --- asciinema/__init__.py | 59 +++++--------------- asciinema/asciicast/raw.py | 30 +++-------- asciinema/asciicast/v2.py | 68 ++--------------------- asciinema/commands/record.py | 6 +-- asciinema/recorder.py | 102 +++++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 135 deletions(-) create mode 100644 asciinema/recorder.py diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 142d49e..3d006a5 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -6,53 +6,20 @@ __version__ = '2.0.2' if sys.version_info[0] < 3: raise ImportError('Python < 3 is unsupported.') - -import os -import subprocess -import time - -import asciinema.asciicast.v2 as v2 -import asciinema.pty as pty -import asciinema.term as term +import asciinema.recorder def record_asciicast(path, command=None, append=False, idle_time_limit=None, rec_stdin=False, title=None, metadata=None, - command_env=None, capture_env=None, writer=v2.async_writer, - record=pty.record): - if command is None: - command = os.environ.get('SHELL') or 'sh' - - if command_env is None: - command_env = os.environ.copy() - command_env['ASCIINEMA_REC'] = '1' - - if capture_env is None: - capture_env = ['SHELL', 'TERM'] - - w, h = term.get_size() - - full_metadata = { - 'width': w, - 'height': h, - 'timestamp': int(time.time()) - } - - full_metadata.update(metadata or {}) - - if idle_time_limit is not None: - full_metadata['idle_time_limit'] = idle_time_limit - - if capture_env: - full_metadata['env'] = {var: command_env.get(var) for var in capture_env} - - if title: - full_metadata['title'] = title - - time_offset = 0 - - if append and os.stat(path).st_size > 0: - time_offset = v2.get_duration(path) - - with writer(path, full_metadata, append, time_offset) as w: - record(['sh', '-c', command], w, command_env, rec_stdin) + command_env=None, capture_env=None): + asciinema.recorder.record( + path, + command=command, + append=append, + idle_time_limit=idle_time_limit, + rec_stdin=rec_stdin, + title=title, + metadata=metadata, + command_env=command_env, + capture_env=capture_env + ) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index ff154bc..1d5dc86 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -1,39 +1,25 @@ import os -from multiprocessing import Process, Queue - - -def write_bytes_from_queue(path, mode, queue): - mode = mode + 'b' - - with open(path, mode=mode, buffering=0) as f: - for data in iter(queue.get, None): - f.write(data) class writer(): - def __init__(self, path, _metadata, append=False, _time_offset=0): + def __init__(self, path, metadata=None, append=False, buffering=0): if append and os.path.exists(path) and os.stat(path).st_size == 0: # true for pipes append = False self.path = path - self.mode = 'a' if append else 'w' - self.queue = Queue() + self.buffering = buffering + self.mode = 'ab' if append else 'wb' def __enter__(self): - self.process = Process( - target=write_bytes_from_queue, - args=(self.path, self.mode, self.queue) - ) - self.process.start() + self.file = open(self.path, mode=self.mode, buffering=self.buffering) return self def __exit__(self, exc_type, exc_value, exc_traceback): - self.queue.put(None) - self.process.join() + self.file.close() + + def write_stdout(self, ts, data): + self.file.write(data) def write_stdin(self, ts, data): pass - - def write_stdout(self, ts, data): - self.queue.put(data) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index bddb419..2c33cbf 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -3,17 +3,6 @@ import json.decoder import time import codecs -try: - # Importing synchronize is to detect platforms where - # multiprocessing does not work (python issue 3770) - # and cause an ImportError. Otherwise it will happen - # later when trying to use Queue(). - from multiprocessing import synchronize, Process, Queue -except ImportError: - from threading import Thread as Process - from queue import Queue - - try: JSONDecodeError = json.decoder.JSONDecodeError except AttributeError: @@ -76,10 +65,9 @@ def get_duration(path): return last_frame[0] -def build_header(metadata): - header = {} +def build_header(width, height, metadata): + header = {'version': 2, 'width': width, 'height': height} header.update(metadata) - header['version'] = 2 assert 'width' in header, 'width missing in metadata' assert 'height' in header, 'height missing in metadata' @@ -94,7 +82,7 @@ def build_header(metadata): class writer(): - def __init__(self, path, width=None, height=None, header=None, append=False, buffering=1): + def __init__(self, path, metadata=None, append=False, buffering=1, width=None, height=None): self.path = path self.buffering = buffering self.stdin_decoder = codecs.getincrementaldecoder('UTF-8')('replace') @@ -105,10 +93,7 @@ class writer(): self.header = None else: self.mode = 'w' - self.header = {'version': 2, 'width': width, 'height': height} - self.header.update(header or {}) - assert type(self.header['width']) == int, 'width or header missing' - assert type(self.header['height']) == int, 'height or header missing' + self.header = build_header(width, height, metadata or {}) def __enter__(self): self.file = open(self.path, mode=self.mode, buffering=self.buffering) @@ -139,48 +124,3 @@ class writer(): def __write_line(self, obj): line = json.dumps(obj, ensure_ascii=False, indent=None, separators=(', ', ': ')) self.file.write(line + '\n') - - -def write_json_lines_from_queue(path, header, append, queue): - with writer(path, header=header, append=append) as w: - for event in iter(queue.get, None): - ts, etype, data = event - - if etype == 'o': - w.write_stdout(ts, data) - elif etype == 'i': - w.write_stdin(ts, data) - - -class async_writer(): - - def __init__(self, path, metadata, append=False, time_offset=0): - if append: - assert time_offset > 0 - - self.path = path - self.metadata = metadata - self.append = append - self.time_offset = time_offset - self.queue = Queue() - - def __enter__(self): - header = build_header(self.metadata) - self.process = Process( - target=write_json_lines_from_queue, - args=(self.path, header, self.append, self.queue) - ) - self.process.start() - return self - - def __exit__(self, exc_type, exc_value, exc_traceback): - self.queue.put(None) - self.process.join() - - def write_stdin(self, ts, data): - ts = ts + self.time_offset - self.queue.put([ts, 'i', data]) - - def write_stdout(self, ts, data): - ts = ts + self.time_offset - self.queue.put([ts, 'o', data]) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 6f61ffa..629f734 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -2,7 +2,7 @@ import os import sys import tempfile -import asciinema +import asciinema.recorder as recorder import asciinema.asciicast.raw as raw import asciinema.asciicast.v2 as v2 from asciinema.api import APIError @@ -24,7 +24,7 @@ class RecordCommand(Command): self.append = args.append self.overwrite = args.overwrite self.raw = args.raw - self.writer = raw.writer if args.raw else v2.async_writer + self.writer = raw.writer if args.raw else v2.writer self.env = env if env is not None else os.environ def execute(self): @@ -66,7 +66,7 @@ class RecordCommand(Command): vars = filter(None, map((lambda var: var.strip()), self.env_whitelist.split(','))) try: - asciinema.record_asciicast( + recorder.record( self.filename, command=self.command, append=append, diff --git a/asciinema/recorder.py b/asciinema/recorder.py new file mode 100644 index 0000000..3bb8836 --- /dev/null +++ b/asciinema/recorder.py @@ -0,0 +1,102 @@ +import os +import time + +try: + # Importing synchronize is to detect platforms where + # multiprocessing does not work (python issue 3770) + # and cause an ImportError. Otherwise it will happen + # later when trying to use Queue(). + from multiprocessing import synchronize, Process, Queue +except ImportError: + from threading import Thread as Process + from queue import Queue + +import asciinema.asciicast.v2 as v2 +import asciinema.pty as pty +import asciinema.term as term + + +def record(path, command=None, append=False, idle_time_limit=None, + rec_stdin=False, title=None, metadata=None, command_env=None, + capture_env=None, writer=v2.writer, record=pty.record): + if command is None: + command = os.environ.get('SHELL') or 'sh' + + if command_env is None: + command_env = os.environ.copy() + command_env['ASCIINEMA_REC'] = '1' + + if capture_env is None: + capture_env = ['SHELL', 'TERM'] + + w, h = term.get_size() + + full_metadata = { + 'width': w, + 'height': h, + 'timestamp': int(time.time()) + } + + full_metadata.update(metadata or {}) + + if idle_time_limit is not None: + full_metadata['idle_time_limit'] = idle_time_limit + + if capture_env: + full_metadata['env'] = {var: command_env.get(var) for var in capture_env} + + if title: + full_metadata['title'] = title + + time_offset = 0 + + if append and os.stat(path).st_size > 0: + time_offset = v2.get_duration(path) + + with async_writer(writer, path, full_metadata, append, time_offset) as w: + record(['sh', '-c', command], w, command_env, rec_stdin) + + +def write_events_from_queue(writer, path, metadata, append, queue): + with writer(path, metadata=metadata, append=append) as w: + for event in iter(queue.get, None): + ts, etype, data = event + + if etype == 'o': + w.write_stdout(ts, data) + elif etype == 'i': + w.write_stdin(ts, data) + + +class async_writer(): + + def __init__(self, writer, path, metadata, append=False, time_offset=0): + if append: + assert time_offset > 0 + + self.writer = writer + self.path = path + self.metadata = metadata + self.append = append + self.time_offset = time_offset + self.queue = Queue() + + def __enter__(self): + self.process = Process( + target=write_events_from_queue, + args=(self.writer, self.path, self.metadata, self.append, self.queue) + ) + self.process.start() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + self.queue.put(None) + self.process.join() + + def write_stdin(self, ts, data): + ts = ts + self.time_offset + self.queue.put([ts, 'i', data]) + + def write_stdout(self, ts, data): + ts = ts + self.time_offset + self.queue.put([ts, 'o', data]) From 7af03d8a184c6d7f22e039c63361c0afb0b5a12c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 23 Mar 2019 15:01:34 +0100 Subject: [PATCH 12/65] Handle time offset in pty.record instead of in writer --- asciinema/pty.py | 4 ++-- asciinema/recorder.py | 12 +++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/asciinema/pty.py b/asciinema/pty.py index 6abdb5d..5780173 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -15,7 +15,7 @@ import time from asciinema.term import raw -def record(command, writer, env=os.environ, rec_stdin=False): +def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): master_fd = None start_time = None @@ -131,7 +131,7 @@ def record(command, writer, env=os.environ, rec_stdin=False): _set_pty_size() - start_time = time.time() + start_time = time.time() - time_offset with raw(pty.STDIN_FILENO): try: diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 3bb8836..b416646 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -53,8 +53,8 @@ def record(path, command=None, append=False, idle_time_limit=None, if append and os.stat(path).st_size > 0: time_offset = v2.get_duration(path) - with async_writer(writer, path, full_metadata, append, time_offset) as w: - record(['sh', '-c', command], w, command_env, rec_stdin) + with async_writer(writer, path, full_metadata, append) as w: + record(['sh', '-c', command], w, command_env, rec_stdin, time_offset) def write_events_from_queue(writer, path, metadata, append, queue): @@ -70,15 +70,11 @@ def write_events_from_queue(writer, path, metadata, append, queue): class async_writer(): - def __init__(self, writer, path, metadata, append=False, time_offset=0): - if append: - assert time_offset > 0 - + def __init__(self, writer, path, metadata, append=False): self.writer = writer self.path = path self.metadata = metadata self.append = append - self.time_offset = time_offset self.queue = Queue() def __enter__(self): @@ -94,9 +90,7 @@ class async_writer(): self.process.join() def write_stdin(self, ts, data): - ts = ts + self.time_offset self.queue.put([ts, 'i', data]) def write_stdout(self, ts, data): - ts = ts + self.time_offset self.queue.put([ts, 'o', data]) From 8ee921ea6dbc4e9bcc0ba1479c139f5e5214cb84 Mon Sep 17 00:00:00 2001 From: KurtPfeifle Date: Tue, 6 Feb 2018 16:22:56 +0100 Subject: [PATCH 13/65] Include generation date + asciinema version in manpage header --- man/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/man/Makefile b/man/Makefile index 67d783a..21c46cb 100644 --- a/man/Makefile +++ b/man/Makefile @@ -1,2 +1,5 @@ +VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"` + asciinema.1: asciinema.1.md - pandoc asciinema.1.md -s -t man -o asciinema.1 + pandoc asciinema.1.md -s -t man -o asciinema.1 -V header:"Version $(VERSION), `date +%Y-%m-%d`" + From 334b795d92a19bd01e4b25f92d9396c5c10dd53a Mon Sep 17 00:00:00 2001 From: KurtPfeifle Date: Tue, 6 Feb 2018 16:24:11 +0100 Subject: [PATCH 14/65] Additions to manpage; modify Markdown to better please future Pandoc --- man/asciinema.1.md | 148 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 112 insertions(+), 36 deletions(-) diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 9666d9d..908cd83 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -1,21 +1,27 @@ % ASCIINEMA(1) Version 2.0.1 | asciinema + NAME ==== **asciinema** - terminal session recorder + SYNOPSIS ======== | **asciinema \-\-version** | **asciinema** _command_ \[_options_] \[_args_] + DESCRIPTION =========== -asciinema lets you easily record terminal sessions and replay -them in a terminal as well as in a web browser. +asciinema lets you easily record terminal sessions, replay +them in a terminal as well as in a web browser and share them on the web. +asciinema is Free and Open Source Software licensed under +the GNU General Public License v3. + COMMANDS ======== @@ -23,9 +29,10 @@ COMMANDS asciinema is composed of multiple commands, similar to `git`, `apt-get` or `brew`. -When you run **asciinema** with no arguments help message is displayed, listing +When you run **asciinema** with no arguments a help message is displayed, listing all available commands with their options. + rec [_filename_] --- @@ -55,37 +62,37 @@ prompt or play a sound when the shell is being recorded. Available options: -:   +:   `--stdin` - : Enable stdin (keyboard) recording (see below) + : Enable stdin (keyboard) recording (see below) `--append` - : Append to existing recording + : Append to existing recording `--raw` - : Save raw STDOUT output, without timing information or other metadata + : Save raw STDOUT output, without timing information or other metadata `--overwrite` - : Overwrite the recording if it already exists + : Overwrite the recording if it already exists `-c, --command=` - : Specify command to record, defaults to **$SHELL** + : Specify command to record, defaults to **$SHELL** `-e, --env=` - : List of environment variables to capture, defaults to **SHELL,TERM** + : List of environment variables to capture, defaults to **SHELL,TERM** `-t, --title=` - : Specify the title of the asciicast + : Specify the title of the asciicast `-i, --idle-time-limit=<sec>` - : Limit recorded terminal inactivity to max `<sec>` seconds + : Limit recorded terminal inactivity to max `<sec>` seconds `-y, --yes` - : Answer "yes" to all prompts (e.g. upload confirmation) + : Answer "yes" to all prompts (e.g. upload confirmation) `-q, --quiet` - : Be quiet, suppress all notices/warnings (implies **-y**) + : Be quiet, suppress all notices/warnings (implies **-y**) Stdin recording allows for capturing of all characters typed in by the user in the currently recorded shell. This may be used by a player (e.g. @@ -94,19 +101,14 @@ pressed keys. Because it's basically a key-logging (scoped to a single shell instance), it's disabled by default, and has to be explicitly enabled via **--stdin** option. + play <_filename_> --- Replay recorded asciicast in a terminal. -This command replays given asciicast (as recorded by **rec** command) directly in -your terminal. - -Following keyboard shortcuts are available: - - Space - toggle pause, - . - step through a recording a frame at a time (when paused), - Ctrl+C - exit. +This command replays a given asciicast (as recorded by **rec** command) directly in +your terminal. The asciicast can be read from a file or from *`stdin`* ('-'): Playing from a local file: @@ -134,14 +136,32 @@ Playing from IPFS: Available options: -:   +:   `-i, --idle-time-limit=<sec>` - : Limit replayed terminal inactivity to max `<sec>` seconds + : Limit replayed terminal inactivity to max `<sec>` seconds (can be fractional) `-s, --speed=<factor>` : Playback speed (can be fractional) +While playing the following keyboard shortcuts are available: + +:   + + *`Space`* + : Toggle pause + + *`.`* + : Step through a recording a frame at a time (when paused) + + *`Ctrl+C`* + : Exit + +Recommendation: run 'asciinema play' in a terminal of dimensions not smaller than the one +used for recording as there's no "transcoding" of control sequences for the new terminal +size. + + cat <_filename_> --- @@ -154,8 +174,9 @@ output (including all escape sequences) to a terminal immediately. **asciinema cat existing.cast >output.txt** gives the same result as recording via **asciinema rec \-\-raw output.txt**. -upload <filename> ---- + +upload <_filename_> +------ Upload recorded asciicast to asciinema.org site. @@ -167,12 +188,12 @@ demo.cast** is a nice combo if you want to review an asciicast before publishing it on asciinema.org. auth ---- +---- -Link your install ID with your asciinema.org user account. +Link and manage your install ID with your asciinema.org user account. If you want to manage your recordings (change title/theme, delete) at -asciinema.org you need to link your "install ID" with asciinema.org user +asciinema.org you need to link your "install ID" with your asciinema.org user account. This command displays the URL to open in a web browser to do that. You may be @@ -187,12 +208,19 @@ account. This way we decouple uploading from account creation, allowing them to happen in any order. Note: A new install ID is generated on each machine and system user account you use -asciinema on, so in order to keep all recordings under a single asciinema.org -account you need to run **asciinema auth** on all of those machines. +asciinema on. So in order to keep all recordings under a single asciinema.org +account you need to run **asciinema auth** on all of those machines. If you’re +already logged in on asciinema.org website and you run 'asciinema auth' from a new +computer then this new device will be linked to your account. + +While you CAN synchronize your config file (which keeps the API token) across +all your machines so all use the same token, that’s not necessary. You can assign +new tokens to your account from as many machines as you want. Note: asciinema versions prior to 2.0 confusingly referred to install ID as "API token". + EXAMPLES ======== @@ -200,6 +228,10 @@ Record your first session: asciinema rec first.cast +End your session: + + exit + Now replay it with double speed: asciinema play -s 2 first.cast @@ -228,25 +260,68 @@ You can record and upload in one step by omitting the filename: You'll be asked to confirm the upload when the recording is done, so nothing is sent anywhere without your consent. + +Tricks +------ + +Record slowly, play faster: + +: First record a session where you can take your time to type slowly what you want + to show in the recording: + + asciinema rec initial.cast + + Then record the replay of 'initial.cast' as 'final.cast', but with five times the + initially recorded speed, with all pauses capped to two seconds and with a title + set as "My fancy title":: + + asciinema rec -c "asciinema play -s 5 -i 2 initial.cast" -t "My fancy title" final.cast + +Play from *`stdin`*: + +:   + + cat /path/to/asciicast.json | asciinema play - + +Play file from remote host accessible with SSH: + +:   + + ssh user@host cat /path/to/asciicat.json | asciinema play - + + ENVIRONMENT =========== **ASCIINEMA_API_URL** -: This variable allows overriding asciinema-server URL (which defaults to - https://asciinema.org) in case you're running your own asciinema-server instance. +: This variable allows overriding asciinema-server URL (which defaults to + https://asciinema.org) in case you're running your own asciinema-server instance. **ASCIINEMA_CONFIG_HOME** -: This variable allows overriding config directory location. Default location - is $XDG\_CONFIG\_HOME/asciinema (when $XDG\_CONFIG\_HOME is set) - or $HOME/.config/asciinema. +: This variable allows overriding config directory location. Default location + is $XDG\_CONFIG\_HOME/asciinema (when $XDG\_CONFIG\_HOME is set) + or $HOME/.config/asciinema. + BUGS ==== See GitHub Issues: <https://github.com/asciinema/asciinema/issues> + +MORE RESSOURCES +=============== + +More documentation is available on the asciicast.org website and its GitHub wiki: + +* Web: [asciinema.org/docs/](https://asciinema.org/docs/) +* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) +* IRC: [Channel on Freenode](https://webchat.freenode.net/?channels=asciinema) +* Twitter: [@asciinema](https://twitter.com/asciinema) + + AUTHORS ======= @@ -255,3 +330,4 @@ asciinema's lead developer is Marcin Kulik. For a list of all contributors look here: <https://github.com/asciinema/asciinema/contributors> This Manual Page was written by Marcin Kulik with help from Kurt Pfeifle. + From 1ad20769eb77f2726e2531ab1e81be9e6435b56b Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 17 Mar 2019 14:48:43 +0100 Subject: [PATCH 15/65] Mute recording with ctrl+p --- asciinema/pty.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/asciinema/pty.py b/asciinema/pty.py index 5780173..ed697cf 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -18,6 +18,7 @@ from asciinema.term import raw def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): master_fd = None start_time = None + muted = False def _set_pty_size(): ''' @@ -42,7 +43,9 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_master_read(data): '''Handles new data on child process stdout.''' - writer.write_stdout(time.time() - start_time, data) + if not muted: + writer.write_stdout(time.time() - start_time, data) + _write_stdout(data) def _write_master(data): @@ -55,10 +58,15 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_stdin_read(data): '''Handles new data on child process stdin.''' - _write_master(data) + nonlocal muted - if rec_stdin: - writer.write_stdin(time.time() - start_time, data) + if data == b'\x10': # ctrl+p + muted = not muted + else: + _write_master(data) + + if rec_stdin and not muted: + writer.write_stdin(time.time() - start_time, data) def _signals(signal_list): old_handlers = [] From bfa266c470a15a980182b285f538753d17c0a1db Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 17 Mar 2019 15:02:15 +0100 Subject: [PATCH 16/65] Document muting --- README.md | 5 +++++ man/asciinema.1.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 22e1367..318b124 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,11 @@ By running `asciinema rec [filename]` you start a new recording session. The command (process) that is recorded can be specified with `-c` option (see below), and defaults to `$SHELL` which is what you want in most cases. +You can temporarily "mute" recording by pressing <kbd>Ctrl+P</kbd>. This is +useful when you want to execute some commands during the recording session that +should not be captured (e.g. pasting secrets). Un-mute by pressing +<kbd>Ctrl+P</kbd> again. + Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when the process exits. diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 908cd83..ab2a687 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -42,6 +42,11 @@ By running **asciinema rec [filename]** you start a new recording session. The command (process) that is recorded can be specified with **-c** option (see below), and defaults to **$SHELL** which is what you want in most cases. +You can temporarily "mute" recording by pressing <kbd>Ctrl+P</kbd>. This is +useful when you want to execute some commands during the recording session that +should not be captured (e.g. pasting secrets). Un-mute by pressing +<kbd>Ctrl+P</kbd> again. + Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when the process exits. From 3ff14875ffbc8608eb8cb4e4713ba7aa61cca9ed Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 17 Mar 2019 18:54:41 +0100 Subject: [PATCH 17/65] pycodestyle --- asciinema/pty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/pty.py b/asciinema/pty.py index ed697cf..2c0a24f 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -60,7 +60,7 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): nonlocal muted - if data == b'\x10': # ctrl+p + if data == b'\x10': # ctrl+p muted = not muted else: _write_master(data) From 53d0dbbfbbfdf46f375576d9ee4684da61c2f954 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 23 Mar 2019 11:59:58 +0100 Subject: [PATCH 18/65] "Pausing" is better than "muting" --- README.md | 6 +++--- asciinema/pty.py | 10 +++++----- man/asciinema.1.md | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 318b124..5692edb 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,9 @@ By running `asciinema rec [filename]` you start a new recording session. The command (process) that is recorded can be specified with `-c` option (see below), and defaults to `$SHELL` which is what you want in most cases. -You can temporarily "mute" recording by pressing <kbd>Ctrl+P</kbd>. This is -useful when you want to execute some commands during the recording session that -should not be captured (e.g. pasting secrets). Un-mute by pressing +You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. +This is useful when you want to execute some commands during the recording +session that should not be captured (e.g. pasting secrets). Resume by pressing <kbd>Ctrl+P</kbd> again. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type diff --git a/asciinema/pty.py b/asciinema/pty.py index 2c0a24f..f008213 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -18,7 +18,7 @@ from asciinema.term import raw def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): master_fd = None start_time = None - muted = False + paused = False def _set_pty_size(): ''' @@ -43,7 +43,7 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_master_read(data): '''Handles new data on child process stdout.''' - if not muted: + if not paused: writer.write_stdout(time.time() - start_time, data) _write_stdout(data) @@ -58,14 +58,14 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_stdin_read(data): '''Handles new data on child process stdin.''' - nonlocal muted + nonlocal paused if data == b'\x10': # ctrl+p - muted = not muted + paused = not paused else: _write_master(data) - if rec_stdin and not muted: + if rec_stdin and not paused: writer.write_stdin(time.time() - start_time, data) def _signals(signal_list): diff --git a/man/asciinema.1.md b/man/asciinema.1.md index ab2a687..23cee46 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -42,9 +42,9 @@ By running **asciinema rec [filename]** you start a new recording session. The command (process) that is recorded can be specified with **-c** option (see below), and defaults to **$SHELL** which is what you want in most cases. -You can temporarily "mute" recording by pressing <kbd>Ctrl+P</kbd>. This is -useful when you want to execute some commands during the recording session that -should not be captured (e.g. pasting secrets). Un-mute by pressing +You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. +This is useful when you want to execute some commands during the recording +session that should not be captured (e.g. pasting secrets). Resume by pressing <kbd>Ctrl+P</kbd> again. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type From e06cabeb2bb71021df8c47bf1356d579616e38c5 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 23 Mar 2019 16:20:43 +0100 Subject: [PATCH 19/65] Stop time when recording session is paused --- asciinema/pty.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/asciinema/pty.py b/asciinema/pty.py index f008213..ffad4ad 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -18,7 +18,7 @@ from asciinema.term import raw def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): master_fd = None start_time = None - paused = False + pause_time = None def _set_pty_size(): ''' @@ -43,7 +43,7 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_master_read(data): '''Handles new data on child process stdout.''' - if not paused: + if not pause_time: writer.write_stdout(time.time() - start_time, data) _write_stdout(data) @@ -58,14 +58,19 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): def _handle_stdin_read(data): '''Handles new data on child process stdin.''' - nonlocal paused + nonlocal pause_time + nonlocal start_time if data == b'\x10': # ctrl+p - paused = not paused + if pause_time: + start_time = start_time + (time.time() - pause_time) + pause_time = None + else: + pause_time = time.time() else: _write_master(data) - if rec_stdin and not paused: + if rec_stdin and not pause_time: writer.write_stdin(time.time() - start_time, data) def _signals(signal_list): From 50722ebd7c97e92b2f0a265af3cbb32612716963 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 23 Mar 2019 18:42:54 +0100 Subject: [PATCH 20/65] Extract asynchronous processing into separate module --- asciinema/async_worker.py | 31 +++++++++++++++++++++++ asciinema/recorder.py | 53 +++++++++++---------------------------- 2 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 asciinema/async_worker.py diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py new file mode 100644 index 0000000..88ad995 --- /dev/null +++ b/asciinema/async_worker.py @@ -0,0 +1,31 @@ +try: + # Importing synchronize is to detect platforms where + # multiprocessing does not work (python issue 3770) + # and cause an ImportError. Otherwise it will happen + # later when trying to use Queue(). + from multiprocessing import synchronize, Process, Queue +except ImportError: + from threading import Thread as Process + from queue import Queue + + +class async_worker(): + + def __init__(self): + self.queue = Queue() + + def __enter__(self): + self.process = Process(target=self.run) + self.process.start() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + self.queue.put(None) + self.process.join() + + def enqueue(self, payload): + self.queue.put(payload) + + def run(self): + for payload in iter(self.queue.get, None): + self.perform(payload) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index b416646..500db42 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -1,19 +1,10 @@ import os import time -try: - # Importing synchronize is to detect platforms where - # multiprocessing does not work (python issue 3770) - # and cause an ImportError. Otherwise it will happen - # later when trying to use Queue(). - from multiprocessing import synchronize, Process, Queue -except ImportError: - from threading import Thread as Process - from queue import Queue - import asciinema.asciicast.v2 as v2 import asciinema.pty as pty import asciinema.term as term +from asciinema.async_worker import async_worker def record(path, command=None, append=False, idle_time_limit=None, @@ -57,40 +48,26 @@ def record(path, command=None, append=False, idle_time_limit=None, record(['sh', '-c', command], w, command_env, rec_stdin, time_offset) -def write_events_from_queue(writer, path, metadata, append, queue): - with writer(path, metadata=metadata, append=append) as w: - for event in iter(queue.get, None): - ts, etype, data = event - - if etype == 'o': - w.write_stdout(ts, data) - elif etype == 'i': - w.write_stdin(ts, data) - - -class async_writer(): - +class async_writer(async_worker): def __init__(self, writer, path, metadata, append=False): + async_worker.__init__(self) self.writer = writer self.path = path self.metadata = metadata self.append = append - self.queue = Queue() - - def __enter__(self): - self.process = Process( - target=write_events_from_queue, - args=(self.writer, self.path, self.metadata, self.append, self.queue) - ) - self.process.start() - return self - - def __exit__(self, exc_type, exc_value, exc_traceback): - self.queue.put(None) - self.process.join() def write_stdin(self, ts, data): - self.queue.put([ts, 'i', data]) + self.enqueue([ts, 'i', data]) def write_stdout(self, ts, data): - self.queue.put([ts, 'o', data]) + self.enqueue([ts, 'o', data]) + + def run(self): + with self.writer(self.path, metadata=self.metadata, append=self.append) as w: + for event in iter(self.queue.get, None): + ts, etype, data = event + + if etype == 'o': + w.write_stdout(ts, data) + elif etype == 'i': + w.write_stdin(ts, data) From 386a8e0f06a71b255dfebfc7d2758967145be4cf Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 23 Mar 2019 19:13:50 +0100 Subject: [PATCH 21/65] First attempt at desktop notifications --- asciinema/notifier.py | 31 +++++++++++++++++++++++++++++++ asciinema/pty.py | 8 +++++++- asciinema/recorder.py | 28 +++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 asciinema/notifier.py diff --git a/asciinema/notifier.py b/asciinema/notifier.py new file mode 100644 index 0000000..3731e31 --- /dev/null +++ b/asciinema/notifier.py @@ -0,0 +1,31 @@ +import shutil +import subprocess + + +class Notifier(): + def is_available(self): + return shutil.which(self.cmd) is not None + + +class AppleScriptNotifier(Notifier): + cmd = "osascript" + + def notify(self, text): + cmd = 'osascript -e \'display notification "{}" with title "asciinema"\''.format(text) + subprocess.run(["/bin/sh", "-c", cmd], capture_output=True) + # we don't want to print *ANYTHING* to the terminal + # so we capture and ignore all output + + +class NoopNotifier(): + def notify(self, text): + pass + + +def get_notifier(): + n = AppleScriptNotifier() + + if n.is_available(): + return n + + return NoopNotifier() diff --git a/asciinema/pty.py b/asciinema/pty.py index ffad4ad..8489bab 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -15,11 +15,15 @@ import time from asciinema.term import raw -def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): +def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None): master_fd = None start_time = None pause_time = None + def _notify(text): + if notifier: + notifier.notify(text) + def _set_pty_size(): ''' Sets the window size of the child pty based on the window size @@ -65,8 +69,10 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0): if pause_time: start_time = start_time + (time.time() - pause_time) pause_time = None + _notify('Resumed recording') else: pause_time = time.time() + _notify('Paused recording') else: _write_master(data) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 500db42..6ff065b 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -4,6 +4,7 @@ import time import asciinema.asciicast.v2 as v2 import asciinema.pty as pty import asciinema.term as term +import asciinema.notifier as notifier from asciinema.async_worker import async_worker @@ -45,7 +46,15 @@ def record(path, command=None, append=False, idle_time_limit=None, time_offset = v2.get_duration(path) with async_writer(writer, path, full_metadata, append) as w: - record(['sh', '-c', command], w, command_env, rec_stdin, time_offset) + with async_notifier() as n: + record( + ['sh', '-c', command], + w, + command_env, + rec_stdin, + time_offset, + n + ) class async_writer(async_worker): @@ -71,3 +80,20 @@ class async_writer(async_worker): w.write_stdout(ts, data) elif etype == 'i': w.write_stdin(ts, data) + + +class async_notifier(async_worker): + def __init__(self): + async_worker.__init__(self) + self.notifier = notifier.get_notifier() + + def notify(self, text): + self.enqueue(text) + + def perform(self, text): + try: + self.notifier.notify(text) + except: + # we catch *ALL* exceptions here because we don't want failed + # notification to crash the recording session + pass From 99281ca2218048bcbd9bd64c83d3770eff1418ef Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 24 Mar 2019 12:17:17 +0100 Subject: [PATCH 22/65] Ignore E722 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e598a8f..86c16cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,5 +13,5 @@ before_install: - pip install pycodestyle script: - - find . -name \*.py -exec pycodestyle --ignore=E501,E402 {} + + - find . -name \*.py -exec pycodestyle --ignore=E501,E402,E722 {} + - make test From 88ff927102265a084bcb6d780d42ded19d9602e8 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 17:48:30 +0200 Subject: [PATCH 23/65] Put pycodestyle ignores to setup.cfg --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index b88034e..3af89fa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,6 @@ [metadata] description-file = README.md + + +[pycodestyle] +ignore = E501,E402,E722 From bd940e936679370dd4fdeda8db0d5e428da200bf Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 19:08:21 +0200 Subject: [PATCH 24/65] Simplify command building --- asciinema/__main__.py | 38 +++++++---------------------------- asciinema/commands/auth.py | 5 ++--- asciinema/commands/cat.py | 6 +++--- asciinema/commands/command.py | 7 +++++-- asciinema/commands/play.py | 10 ++++----- asciinema/commands/record.py | 8 ++++---- asciinema/commands/upload.py | 7 +++---- 7 files changed, 29 insertions(+), 52 deletions(-) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index 3650881..e1702a1 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -10,7 +10,6 @@ from asciinema.commands.record import RecordCommand from asciinema.commands.play import PlayCommand from asciinema.commands.cat import CatCommand from asciinema.commands.upload import UploadCommand -from asciinema.api import Api def positive_float(value): @@ -21,29 +20,6 @@ def positive_float(value): return value -def rec_command(args, config): - api = Api(config.api_url, os.environ.get("USER"), config.install_id) - return RecordCommand(api, args) - - -def play_command(args, config): - return PlayCommand(args.filename, args.idle_time_limit, args.speed) - - -def cat_command(args, config): - return CatCommand(args.filename) - - -def upload_command(args, config): - api = Api(config.api_url, os.environ.get("USER"), config.install_id) - return UploadCommand(api, args.filename) - - -def auth_command(args, config): - api = Api(config.api_url, os.environ.get("USER"), config.install_id) - return AuthCommand(api) - - def maybe_str(v): if v is not None: return str(v) @@ -100,34 +76,34 @@ For help on a specific command run: parser_rec.add_argument('-y', '--yes', help='answer "yes" to all prompts (e.g. upload confirmation)', action='store_true', default=cfg.record_yes) parser_rec.add_argument('-q', '--quiet', help='be quiet, suppress all notices/warnings (implies -y)', action='store_true', default=cfg.record_quiet) parser_rec.add_argument('filename', nargs='?', default='', help='filename/path to save the recording to') - parser_rec.set_defaults(func=rec_command) + parser_rec.set_defaults(cmd=RecordCommand) # create the parser for the "play" command parser_play = subparsers.add_parser('play', help='Replay terminal session') parser_play.add_argument('-i', '--idle-time-limit', help='limit idle time during playback to given number of seconds', type=positive_float, default=maybe_str(cfg.play_idle_time_limit)) parser_play.add_argument('-s', '--speed', help='playback speedup (can be fractional)', type=positive_float, default=cfg.play_speed) parser_play.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)') - parser_play.set_defaults(func=play_command) + parser_play.set_defaults(cmd=PlayCommand) # create the parser for the "cat" command parser_cat = subparsers.add_parser('cat', help='Print full output of terminal session') parser_cat.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)') - parser_cat.set_defaults(func=cat_command) + parser_cat.set_defaults(cmd=CatCommand) # create the parser for the "upload" command parser_upload = subparsers.add_parser('upload', help='Upload locally saved terminal session to asciinema.org') parser_upload.add_argument('filename', help='filename or path of local recording') - parser_upload.set_defaults(func=upload_command) + parser_upload.set_defaults(cmd=UploadCommand) # create the parser for the "auth" command parser_auth = subparsers.add_parser('auth', help='Manage recordings on asciinema.org account') - parser_auth.set_defaults(func=auth_command) + parser_auth.set_defaults(cmd=AuthCommand) # parse the args and call whatever function was selected args = parser.parse_args() - if hasattr(args, 'func'): - command = args.func(args, cfg) + if hasattr(args, 'cmd'): + command = args.cmd(args, cfg, os.environ) code = command.execute() sys.exit(code) else: diff --git a/asciinema/commands/auth.py b/asciinema/commands/auth.py index 1fab7f6..cc435ce 100644 --- a/asciinema/commands/auth.py +++ b/asciinema/commands/auth.py @@ -3,9 +3,8 @@ from asciinema.commands.command import Command class AuthCommand(Command): - def __init__(self, api): - Command.__init__(self) - self.api = api + def __init__(self, args, config, env): + Command.__init__(self, args, config, env) def execute(self): self.print('Open the following URL in a web browser to link your ' diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index 2fe285a..53aa0eb 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -6,9 +6,9 @@ import asciinema.asciicast as asciicast class CatCommand(Command): - def __init__(self, filename): - Command.__init__(self) - self.filename = filename + def __init__(self, args, config, env): + Command.__init__(self, args, config, env) + self.filename = args.filename def execute(self): try: diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index bd4260e..3ba5ad0 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -1,10 +1,13 @@ import sys +from asciinema.api import Api + class Command: - def __init__(self, quiet=False): - self.quiet = quiet + def __init__(self, args, config, env): + self.quiet = False + self.api = Api(config.api_url, env.get("USER"), config.install_id) def print(self, text, file=sys.stdout, end="\n", force=False): if not self.quiet or force: diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py index 4998d67..cf04d9b 100644 --- a/asciinema/commands/play.py +++ b/asciinema/commands/play.py @@ -5,11 +5,11 @@ import asciinema.asciicast as asciicast class PlayCommand(Command): - def __init__(self, filename, idle_time_limit, speed, player=None): - Command.__init__(self) - self.filename = filename - self.idle_time_limit = idle_time_limit - self.speed = speed + def __init__(self, args, config, env, player=None): + Command.__init__(self, args, config, env) + self.filename = args.filename + self.idle_time_limit = args.idle_time_limit + self.speed = args.speed self.player = player if player is not None else Player() def execute(self): diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 629f734..2607597 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -11,9 +11,9 @@ from asciinema.commands.command import Command class RecordCommand(Command): - def __init__(self, api, args, env=None): - Command.__init__(self, args.quiet) - self.api = api + def __init__(self, args, config, env): + Command.__init__(self, args, config, env) + self.quiet = args.quiet self.filename = args.filename self.rec_stdin = args.stdin self.command = args.command @@ -25,7 +25,7 @@ class RecordCommand(Command): self.overwrite = args.overwrite self.raw = args.raw self.writer = raw.writer if args.raw else v2.writer - self.env = env if env is not None else os.environ + self.env = env def execute(self): upload = False diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py index 7ca25dc..897ecb5 100644 --- a/asciinema/commands/upload.py +++ b/asciinema/commands/upload.py @@ -4,10 +4,9 @@ from asciinema.api import APIError class UploadCommand(Command): - def __init__(self, api, filename): - Command.__init__(self) - self.api = api - self.filename = filename + def __init__(self, args, config, env): + Command.__init__(self, args, config, env) + self.filename = args.filename def execute(self): try: From f993ced914f833828bd40ca188d5c1fa87b57c6b Mon Sep 17 00:00:00 2001 From: ibrakap <32246627+ibrakap@users.noreply.github.com> Date: Wed, 3 Apr 2019 18:49:46 +0300 Subject: [PATCH 25/65] pretty syntax --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 950a04d..a6a3625 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import asciinema import sys from setuptools import setup -if sys.version_info[0] < 3: +if sys.version_info.major < 3: sys.exit('Python < 3 is unsupported.') url_template = 'https://github.com/asciinema/asciinema/archive/v%s.tar.gz' From 30ed6cb98c29807b3ec35366c5ebc984ff649ff5 Mon Sep 17 00:00:00 2001 From: David Bradway <david.bradway@gmail.com> Date: Tue, 9 Apr 2019 14:09:11 -0400 Subject: [PATCH 26/65] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5692edb..f61dd23 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ asked to log in first. Install ID is a random ID ([UUID v4](https://en.wikipedia.org/wiki/Universally_unique_identifier)) generated locally when you run asciinema for the first time, and saved at -`$HOME/.config/asciinema/install-id`. It's purpose is to connect local machine +`$HOME/.config/asciinema/install-id`. Its purpose is to connect local machine with uploaded recordings, so they can later be associated with asciinema.org account. This way we decouple uploading from account creation, allowing them to happen in any order. From 631831deadfc2d6ee55fff9d8c035caf5ef38d56 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 30 Mar 2019 20:43:36 +0100 Subject: [PATCH 27/65] Fix double quote escaping in AppleScript notifier --- asciinema/notifier.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/asciinema/notifier.py b/asciinema/notifier.py index 3731e31..fae4f57 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -6,15 +6,18 @@ class Notifier(): def is_available(self): return shutil.which(self.cmd) is not None + def notify(self, text): + subprocess.run(self.args(text), capture_output=True) + # we don't want to print *ANYTHING* to the terminal + # so we capture and ignore all output + class AppleScriptNotifier(Notifier): cmd = "osascript" - def notify(self, text): - cmd = 'osascript -e \'display notification "{}" with title "asciinema"\''.format(text) - subprocess.run(["/bin/sh", "-c", cmd], capture_output=True) - # we don't want to print *ANYTHING* to the terminal - # so we capture and ignore all output + def args(self, text): + text = text.replace('"', '\\"') + return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)] class NoopNotifier(): From 99e2fa99c068963a2a4d8b56dea007c85fac4e2d Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 13:29:14 +0200 Subject: [PATCH 28/65] Add libnotify notifier --- asciinema/notifier.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/asciinema/notifier.py b/asciinema/notifier.py index fae4f57..8b24668 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -20,15 +20,23 @@ class AppleScriptNotifier(Notifier): return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)] +class LibNotifyNotifier(Notifier): + cmd = "notify-send" + + def args(self, text): + return ['notify-send', 'asciinema', text] + + class NoopNotifier(): def notify(self, text): pass def get_notifier(): - n = AppleScriptNotifier() + for c in [AppleScriptNotifier, LibNotifyNotifier]: + n = c() - if n.is_available(): - return n + if n.is_available(): + return n return NoopNotifier() From 9875622a0c6003077fde0874758faf4f7b3a28dd Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 14:13:04 +0200 Subject: [PATCH 29/65] Add terminal-notifier support --- asciinema/notifier.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/asciinema/notifier.py b/asciinema/notifier.py index 8b24668..815768e 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -27,13 +27,20 @@ class LibNotifyNotifier(Notifier): return ['notify-send', 'asciinema', text] +class TerminalNotifier(Notifier): + cmd = "terminal-notifier" + + def args(self, text): + return ['terminal-notifier', '-title', 'asciinema', '-message', text] + + class NoopNotifier(): def notify(self, text): pass def get_notifier(): - for c in [AppleScriptNotifier, LibNotifyNotifier]: + for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: n = c() if n.is_available(): From cf2f8400a29ce61411fa608cafd6b179d96f6eb9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 14:50:42 +0200 Subject: [PATCH 30/65] Use icon with send-notify and terminal-notifier --- asciinema/data/icon-256x256.png | Bin 0 -> 25280 bytes asciinema/notifier.py | 21 +++++++++++++++++++-- setup.py | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 asciinema/data/icon-256x256.png diff --git a/asciinema/data/icon-256x256.png b/asciinema/data/icon-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..a0885aa8b215cd8fe73fe530db146b277ecd58cc GIT binary patch literal 25280 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 literal 0 HcmV?d00001 diff --git a/asciinema/notifier.py b/asciinema/notifier.py index 815768e..af0d6eb 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -1,3 +1,4 @@ +import os.path import shutil import subprocess @@ -11,6 +12,12 @@ class Notifier(): # we don't want to print *ANYTHING* to the terminal # so we capture and ignore all output + def get_icon_path(self): + path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data/icon-256x256.png") + + if os.path.exists(path): + return path + class AppleScriptNotifier(Notifier): cmd = "osascript" @@ -24,14 +31,24 @@ class LibNotifyNotifier(Notifier): cmd = "notify-send" def args(self, text): - return ['notify-send', 'asciinema', text] + icon_path = self.get_icon_path() + + if icon_path is not None: + return ['notify-send', '-i', icon_path, 'asciinema', text] + else: + return ['notify-send', 'asciinema', text] class TerminalNotifier(Notifier): cmd = "terminal-notifier" def args(self, text): - return ['terminal-notifier', '-title', 'asciinema', '-message', text] + icon_path = self.get_icon_path() + + if icon_path is not None: + return ['terminal-notifier', '-title', 'asciinema', '-message', text, '-appIcon', icon_path] + else: + return ['terminal-notifier', '-title', 'asciinema', '-message', text] class NoopNotifier(): diff --git a/setup.py b/setup.py index a6a3625..d40539c 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ setup( 'asciinema = asciinema.__main__:main', ], }, + package_data={'asciinema': ['data/*.png']}, data_files=[('share/doc/asciinema', ['CHANGELOG.md', 'CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', From bc793d541c36045ab3453c0a67084242e9848ce7 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 18:38:00 +0200 Subject: [PATCH 31/65] Custom notification command + disabling notifications --- README.md | 8 ++++++++ asciinema/commands/record.py | 5 ++++- asciinema/config.py | 8 ++++++++ asciinema/notifier.py | 27 ++++++++++++++++++++++----- asciinema/recorder.py | 12 ++++++------ tests/config_test.py | 19 +++++++++++++++++++ 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f61dd23..3ad49dd 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,14 @@ speed = 2 ; Limit replayed terminal inactivity to max n seconds, default: off idle_time_limit = 1 + +[notifications] + +; Should desktop notifications be enabled, default: yes +enabled = no + +; Custom notification command +command = tmux display-message "$TEXT" ``` A very minimal config file could look like that: diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 2607597..904ba5a 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -5,6 +5,7 @@ import tempfile import asciinema.recorder as recorder import asciinema.asciicast.raw as raw import asciinema.asciicast.v2 as v2 +import asciinema.notifier as notifier from asciinema.api import APIError from asciinema.commands.command import Command @@ -25,6 +26,7 @@ class RecordCommand(Command): self.overwrite = args.overwrite self.raw = args.raw self.writer = raw.writer if args.raw else v2.writer + self.notifier = notifier.get_notifier(config.notifications_enabled, config.notifications_command) self.env = env def execute(self): @@ -75,7 +77,8 @@ class RecordCommand(Command): command_env=self.env, capture_env=vars, rec_stdin=self.rec_stdin, - writer=self.writer + writer=self.writer, + notifier=self.notifier ) except v2.LoadError: self.print_error("can only append to asciicast v2 format recordings") diff --git a/asciinema/config.py b/asciinema/config.py index 62f3e17..3121a49 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -118,6 +118,14 @@ class Config: def play_speed(self): return self.config.getfloat('play', 'speed', fallback=1.0) + @property + def notifications_enabled(self): + return self.config.getboolean('notifications', 'enabled', fallback=True) + + @property + def notifications_command(self): + return self.config.get('notifications', 'command', fallback=None) + def get_config_home(env=os.environ): env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME") diff --git a/asciinema/notifier.py b/asciinema/notifier.py index af0d6eb..f7289fe 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -51,16 +51,33 @@ class TerminalNotifier(Notifier): return ['terminal-notifier', '-title', 'asciinema', '-message', text] +class CustomCommandNotifier(Notifier): + def __init__(self, command): + Notifier.__init__(self) + self.command = command + + def notify(self, text): + args = ['/bin/sh', '-c', self.command] + env = os.environ.copy() + env['TEXT'] = text + env['ICON_PATH'] = self.get_icon_path() + subprocess.run(args, env=env, capture_output=True) + + class NoopNotifier(): def notify(self, text): pass -def get_notifier(): - for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: - n = c() +def get_notifier(enabled=True, command=None): + if enabled: + if command is not None: + return CustomCommandNotifier(command) + else: + for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: + n = c() - if n.is_available(): - return n + if n.is_available(): + return n return NoopNotifier() diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 6ff065b..37811af 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -4,13 +4,12 @@ import time import asciinema.asciicast.v2 as v2 import asciinema.pty as pty import asciinema.term as term -import asciinema.notifier as notifier from asciinema.async_worker import async_worker def record(path, command=None, append=False, idle_time_limit=None, rec_stdin=False, title=None, metadata=None, command_env=None, - capture_env=None, writer=v2.writer, record=pty.record): + capture_env=None, writer=v2.writer, record=pty.record, notifier=None): if command is None: command = os.environ.get('SHELL') or 'sh' @@ -46,7 +45,7 @@ def record(path, command=None, append=False, idle_time_limit=None, time_offset = v2.get_duration(path) with async_writer(writer, path, full_metadata, append) as w: - with async_notifier() as n: + with async_notifier(notifier) as n: record( ['sh', '-c', command], w, @@ -83,16 +82,17 @@ class async_writer(async_worker): class async_notifier(async_worker): - def __init__(self): + def __init__(self, notifier): async_worker.__init__(self) - self.notifier = notifier.get_notifier() + self.notifier = notifier def notify(self, text): self.enqueue(text) def perform(self, text): try: - self.notifier.notify(text) + if self.notifier: + self.notifier.notify(text) except: # we catch *ALL* exceptions here because we don't want failed # notification to crash the recording session diff --git a/tests/config_test.py b/tests/config_test.py index a2ba67d..6bb1a08 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -187,3 +187,22 @@ def test_play_idle_time_limit(): config = create_config("[play]\nmaxwait = 2.35") assert_equal(2.35, config.play_idle_time_limit) + + +def test_notifications_enabled(): + config = create_config('') + assert_equal(True, config.notifications_enabled) + + config = create_config("[notifications]\nenabled = yes") + assert_equal(True, config.notifications_enabled) + + config = create_config("[notifications]\nenabled = no") + assert_equal(False, config.notifications_enabled) + + +def test_notifications_command(): + config = create_config('') + assert_equal(None, config.notifications_command) + + config = create_config('[notifications]\ncommand = tmux display-message "$TEXT"') + assert_equal('tmux display-message "$TEXT"', config.notifications_command) From e8a493239e87c1af92077ce2b63695379c29734c Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 19:46:03 +0200 Subject: [PATCH 32/65] Ignore empty ("") custom notification command --- asciinema/notifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/notifier.py b/asciinema/notifier.py index f7289fe..1240511 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -71,7 +71,7 @@ class NoopNotifier(): def get_notifier(enabled=True, command=None): if enabled: - if command is not None: + if command: return CustomCommandNotifier(command) else: for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: From 350d5e9d8b7cee1be5db4091815942c9d035e7dc Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 Mar 2019 19:48:41 +0200 Subject: [PATCH 33/65] Explain env var in custom notification command --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3ad49dd..023f308 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ idle_time_limit = 1 enabled = no ; Custom notification command +; Environment variable $TEXT contains notification text command = tmux display-message "$TEXT" ``` From 6a4ca6f1d2f8fe61a7e5f712831513425c616306 Mon Sep 17 00:00:00 2001 From: landonb <landonb@users.noreply.github.com> Date: Tue, 24 Mar 2020 23:53:10 -0500 Subject: [PATCH 34/65] Bugfix: Ensure ASCIINEMA_REC set on record (#372). --- asciinema/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 37811af..5c51319 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -15,7 +15,7 @@ def record(path, command=None, append=False, idle_time_limit=None, if command_env is None: command_env = os.environ.copy() - command_env['ASCIINEMA_REC'] = '1' + command_env['ASCIINEMA_REC'] = '1' if capture_env is None: capture_env = ['SHELL', 'TERM'] From e2df16b6b74acede1d13eef332646beda6f70315 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Fri, 17 Jan 2020 21:01:08 +0100 Subject: [PATCH 35/65] Upgrade base Docker image --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0c037a2..b70c6b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM ubuntu:19.04 RUN apt-get update && apt-get install -y \ ca-certificates \ From 76b8248893b6462b016cbe6187820a35276f1ef9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 19 Apr 2020 13:46:42 +0200 Subject: [PATCH 36/65] Whitespace --- asciinema/recorder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 5c51319..41a5202 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -15,6 +15,7 @@ def record(path, command=None, append=False, idle_time_limit=None, if command_env is None: command_env = os.environ.copy() + command_env['ASCIINEMA_REC'] = '1' if capture_env is None: From 63f27866bde0cd79f013c5d12878993a7f2995a8 Mon Sep 17 00:00:00 2001 From: Michael Osipov <michael.osipov@siemens.com> Date: Fri, 27 Sep 2019 08:58:37 +0200 Subject: [PATCH 37/65] Fix codeset detection on HP-UX On HP-UX UTF-8 aware locales end with '.utf8'. --- asciinema/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index e1702a1..73d715e 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -26,7 +26,7 @@ def maybe_str(v): def main(): - if locale.nl_langinfo(locale.CODESET).upper() not in ['US-ASCII', 'UTF-8']: + if locale.nl_langinfo(locale.CODESET).upper() not in ['US-ASCII', 'UTF-8', 'UTF8']: print("asciinema needs an ASCII or UTF-8 character encoding to run. Check the output of `locale` command.") sys.exit(1) From e966e09f63c3fba8bb9649dace9142379ab8a0cf Mon Sep 17 00:00:00 2001 From: "Devin J. Pohly" <djpohly@gmail.com> Date: Fri, 20 Mar 2020 19:38:47 -0500 Subject: [PATCH 38/65] Consume terminal answerbacks in asciinema cat If the asciicast contains escape sequence queries like "CSI 6 n", the terminal will both echo and input its responses. Use the raw() context manager that we already have to attempt to consume this "input." There is, unfortunately, no way of finding out exactly when the terminal has finished its answerbacks. This patch adds a 50ms wait, which should be overkill for a local terminal (in my tests, 3ms was usually enough). For a remote terminal, this number becomes harder to estimate. Technically this only needs to be done if whatever we're writing to isatty(), but keep it simple for now. --- asciinema/commands/cat.py | 11 +++++++---- asciinema/term.py | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index 53aa0eb..fe12c90 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -1,6 +1,7 @@ import sys from asciinema.commands.command import Command +from asciinema.term import raw import asciinema.asciicast as asciicast @@ -12,10 +13,12 @@ class CatCommand(Command): def execute(self): try: - with asciicast.open_from_url(self.filename) as a: - for t, _type, text in a.stdout_events(): - sys.stdout.write(text) - sys.stdout.flush() + stdin = open('/dev/tty') + with raw(stdin.fileno()): + with asciicast.open_from_url(self.filename) as a: + for t, _type, text in a.stdout_events(): + sys.stdout.write(text) + sys.stdout.flush() except asciicast.LoadError as e: self.print_error("printing failed: %s" % str(e)) diff --git a/asciinema/term.py b/asciinema/term.py index 49858ce..7ae1830 100644 --- a/asciinema/term.py +++ b/asciinema/term.py @@ -1,6 +1,7 @@ import os import select import subprocess +import time import tty @@ -19,6 +20,8 @@ class raw(): def __exit__(self, type, value, traceback): if self.restore: + # Give the terminal time to send answerbacks + time.sleep(0.05) tty.tcsetattr(self.fd, tty.TCSAFLUSH, self.mode) From 4093b42f35966459ebefd0c0f596342287890e4f Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 17 May 2020 23:10:34 +0200 Subject: [PATCH 39/65] Lower terminal answerback time to 10ms --- asciinema/term.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/term.py b/asciinema/term.py index 7ae1830..c1b6dfa 100644 --- a/asciinema/term.py +++ b/asciinema/term.py @@ -21,7 +21,7 @@ class raw(): def __exit__(self, type, value, traceback): if self.restore: # Give the terminal time to send answerbacks - time.sleep(0.05) + time.sleep(0.01) tty.tcsetattr(self.fd, tty.TCSAFLUSH, self.mode) From c85a4a7acd04a6b6d1b6da9adc0b45afa10f8453 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 19 Apr 2020 18:53:13 +0200 Subject: [PATCH 40/65] Configurable hotkeys for recording --- README.md | 8 ++++++++ asciinema/commands/record.py | 7 ++++++- asciinema/config.py | 22 ++++++++++++++++++++ asciinema/pty.py | 39 ++++++++++++++++++++++++------------ asciinema/recorder.py | 6 ++++-- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 023f308..a2f0b71 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,14 @@ yes = true ; Be quiet, suppress all notices/warnings, default: no quiet = true +; Define hotkey for pausing recording (suspending capture of output), +; default: C-\ +pause_key = C-p + +; Define hotkey prefix key - when defined other recording hotkeys must +; be preceeded by it, default: no prefix +prefix_key = C-a + [play] ; Playback speed (can be fractional), default: 1 diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 904ba5a..b91d472 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -28,6 +28,10 @@ class RecordCommand(Command): self.writer = raw.writer if args.raw else v2.writer self.notifier = notifier.get_notifier(config.notifications_enabled, config.notifications_command) self.env = env + self.key_bindings = { + 'prefix': config.record_prefix_key, + 'pause': config.record_pause_key + } def execute(self): upload = False @@ -78,7 +82,8 @@ class RecordCommand(Command): capture_env=vars, rec_stdin=self.rec_stdin, writer=self.writer, - notifier=self.notifier + notifier=self.notifier, + key_bindings=self.key_bindings ) except v2.LoadError: self.print_error("can only append to asciicast v2 format recordings") diff --git a/asciinema/config.py b/asciinema/config.py index 3121a49..a7ae0b5 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -109,6 +109,14 @@ class Config: def record_quiet(self): return self.config.getboolean('record', 'quiet', fallback=False) + @property + def record_prefix_key(self): + return self.__get_key('record', 'prefix') + + @property + def record_pause_key(self): + return self.__get_key('record', 'pause', 'C-\\') + @property def play_idle_time_limit(self): fallback = self.config.getfloat('play', 'maxwait', fallback=None) # pre 2.0 @@ -126,6 +134,20 @@ class Config: def notifications_command(self): return self.config.get('notifications', 'command', fallback=None) + def __get_key(self, section, name, default=None): + key = self.config.get(section, f'{name}_key', fallback=default) + + if key: + if len(key) == 3: + upper_key = key.upper() + + if upper_key[0] == 'C' and upper_key[1] == '-': + return bytes([ord(upper_key[2]) - 0x40]) + else: + raise ConfigError(f'invalid {name} key definition \'{key}\' - use: {name}_key = C-x (with control key modifier), or {name}_key = x (with no modifier)') + else: + return key.encode('utf-8') + def get_config_home(env=os.environ): env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME") diff --git a/asciinema/pty.py b/asciinema/pty.py index 8489bab..e5d66a6 100644 --- a/asciinema/pty.py +++ b/asciinema/pty.py @@ -15,10 +15,13 @@ import time from asciinema.term import raw -def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None): +def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None, key_bindings={}): master_fd = None start_time = None pause_time = None + prefix_mode = False + prefix_key = key_bindings.get('prefix') + pause_key = key_bindings.get('pause') def _notify(text): if notifier: @@ -64,20 +67,30 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti nonlocal pause_time nonlocal start_time + nonlocal prefix_mode - if data == b'\x10': # ctrl+p - if pause_time: - start_time = start_time + (time.time() - pause_time) - pause_time = None - _notify('Resumed recording') - else: - pause_time = time.time() - _notify('Paused recording') - else: - _write_master(data) + if not prefix_mode and prefix_key and data == prefix_key: + prefix_mode = True + return - if rec_stdin and not pause_time: - writer.write_stdin(time.time() - start_time, data) + if prefix_mode or (not prefix_key and data in [pause_key]): + prefix_mode = False + + if data == pause_key: + if pause_time: + start_time = start_time + (time.time() - pause_time) + pause_time = None + _notify('Resumed recording') + else: + pause_time = time.time() + _notify('Paused recording') + + return + + _write_master(data) + + if rec_stdin and not pause_time: + writer.write_stdin(time.time() - start_time, data) def _signals(signal_list): old_handlers = [] diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 41a5202..1c02d49 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -9,7 +9,8 @@ from asciinema.async_worker import async_worker def record(path, command=None, append=False, idle_time_limit=None, rec_stdin=False, title=None, metadata=None, command_env=None, - capture_env=None, writer=v2.writer, record=pty.record, notifier=None): + capture_env=None, writer=v2.writer, record=pty.record, notifier=None, + key_bindings={}): if command is None: command = os.environ.get('SHELL') or 'sh' @@ -53,7 +54,8 @@ def record(path, command=None, append=False, idle_time_limit=None, command_env, rec_stdin, time_offset, - n + n, + key_bindings ) From 0eec28ddb2c175e124afe73e194f2a62f2e83649 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 19 Apr 2020 19:08:39 +0200 Subject: [PATCH 41/65] Configurable hotkeys for playback --- README.md | 8 ++++++++ asciinema/commands/play.py | 6 +++++- asciinema/config.py | 8 ++++++++ asciinema/player.py | 16 +++++++++------- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a2f0b71..2958a67 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,14 @@ speed = 2 ; Limit replayed terminal inactivity to max n seconds, default: off idle_time_limit = 1 +; Define hotkey for pausing/resuming playback, +; default: space +pause_key = p + +; Define hotkey for stepping through playback, a frame at a time, +; default: . +pause_key = ] + [notifications] ; Should desktop notifications be enabled, default: yes diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py index cf04d9b..dd5adf3 100644 --- a/asciinema/commands/play.py +++ b/asciinema/commands/play.py @@ -11,11 +11,15 @@ class PlayCommand(Command): self.idle_time_limit = args.idle_time_limit self.speed = args.speed self.player = player if player is not None else Player() + self.key_bindings = { + 'pause': config.play_pause_key, + 'step': config.play_step_key + } def execute(self): try: with asciicast.open_from_url(self.filename) as a: - self.player.play(a, self.idle_time_limit, self.speed) + self.player.play(a, self.idle_time_limit, self.speed, self.key_bindings) except asciicast.LoadError as e: self.print_error("playback failed: %s" % str(e)) diff --git a/asciinema/config.py b/asciinema/config.py index a7ae0b5..93109c1 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -126,6 +126,14 @@ class Config: def play_speed(self): return self.config.getfloat('play', 'speed', fallback=1.0) + @property + def play_pause_key(self): + return self.__get_key('play', 'pause', ' ') + + @property + def play_step_key(self): + return self.__get_key('play', 'step', '.') + @property def notifications_enabled(self): return self.config.getboolean('notifications', 'enabled', fallback=True) diff --git a/asciinema/player.py b/asciinema/player.py index e81b501..3670a8d 100644 --- a/asciinema/player.py +++ b/asciinema/player.py @@ -8,16 +8,18 @@ from asciinema.term import raw, read_blocking class Player: - def play(self, asciicast, idle_time_limit=None, speed=1.0): + def play(self, asciicast, idle_time_limit=None, speed=1.0, key_bindings={}): try: stdin = open('/dev/tty') with raw(stdin.fileno()): - self._play(asciicast, idle_time_limit, speed, stdin) + self._play(asciicast, idle_time_limit, speed, stdin, key_bindings) except Exception: - self._play(asciicast, idle_time_limit, speed, None) + self._play(asciicast, idle_time_limit, speed, None, key_bindings) - def _play(self, asciicast, idle_time_limit, speed, stdin): + def _play(self, asciicast, idle_time_limit, speed, stdin, key_bindings): idle_time_limit = idle_time_limit or asciicast.idle_time_limit + pause_key = key_bindings.get('pause') + step_key = key_bindings.get('step') stdout = asciicast.stdout_events() stdout = ev.to_relative_time(stdout) @@ -42,12 +44,12 @@ class Player: ctrl_c = True break - if 0x20 in data: # space + if data == pause_key: paused = False base_time = base_time + (time.time() - pause_time) break - if 0x2e in data: # period (dot) + if data == step_key: delay = 0 pause_time = time.time() base_time = pause_time - t @@ -62,7 +64,7 @@ class Player: ctrl_c = True break - if 0x20 in data: # space + if data == pause_key: paused = True pause_time = time.time() slept = t - (pause_time - base_time) From c94e898ec14c35db294974d4c3370fb154eba298 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 19 Apr 2020 21:50:38 +0200 Subject: [PATCH 42/65] Don't use new f'' string syntax - it doesn't work on Python <3.6 --- asciinema/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asciinema/config.py b/asciinema/config.py index 93109c1..75703c0 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -143,7 +143,7 @@ class Config: return self.config.get('notifications', 'command', fallback=None) def __get_key(self, section, name, default=None): - key = self.config.get(section, f'{name}_key', fallback=default) + key = self.config.get(section, name + '_key', fallback=default) if key: if len(key) == 3: @@ -152,7 +152,7 @@ class Config: if upper_key[0] == 'C' and upper_key[1] == '-': return bytes([ord(upper_key[2]) - 0x40]) else: - raise ConfigError(f'invalid {name} key definition \'{key}\' - use: {name}_key = C-x (with control key modifier), or {name}_key = x (with no modifier)') + raise ConfigError('invalid {name} key definition \'{key}\' - use: {name}_key = C-x (with control key modifier), or {name}_key = x (with no modifier)'.format(name=name, key=key)) else: return key.encode('utf-8') From 46946e7506519f531d4a723476a0be70cbd08045 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 31 May 2020 12:42:42 +0200 Subject: [PATCH 43/65] Suggest --overwrite option when dest file exists --- asciinema/commands/record.py | 1 + 1 file changed, 1 insertion(+) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index b91d472..b8f9423 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -56,6 +56,7 @@ class RecordCommand(Command): elif os.stat(self.filename).st_size > 0 and not append: self.print_error("%s already exists, aborting" % self.filename) + self.print_error("use --overwrite option if you want to overwrite existing recording") self.print_error("use --append option if you want to append to existing recording") return 1 From d966bd013cd5815f9acf3a8287e2b98453b42f8d Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Mon, 28 Dec 2020 12:32:12 +0100 Subject: [PATCH 44/65] Fix demo cast URL in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2958a67..a02efbf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ _Note: This is README for `development` branch. [See the version for latest stab Terminal session recorder and the best companion of [asciinema.org](https://asciinema.org). -[![demo](https://asciinema.org/a/113463.svg)](https://asciinema.org/a/113463?autoplay=1) +[![demo](https://asciinema.org/a/335480.svg)](https://asciinema.org/a/335480?autoplay=1) ## Quick intro From 2439b01ff46c1517f4fd18d16a3f2aebea3f7c08 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 3 Jan 2021 18:58:24 +0100 Subject: [PATCH 45/65] Fix broken link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a02efbf..dde7c3e 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ If you prefer to host the recordings yourself, you can do so by either: - setting up your own [asciinema-server](https://github.com/asciinema/asciinema-server) instance, and [setting API URL - accordingly](https://github.com/asciinema/asciinema-server/blob/master/docs/INSTALL.md#using-asciinema-recorder-with-your-instance). + accordingly](https://github.com/asciinema/asciinema-server/wiki/Installation-guide#using-asciinema-recorder-with-your-instance). ## Configuration file From 5816099c4bd3c151144414f5a245405b926d6c76 Mon Sep 17 00:00:00 2001 From: freddii <freddii@users.noreply.github.com> Date: Thu, 14 Jan 2021 10:22:05 +0100 Subject: [PATCH 46/65] fixed spelling mistake --- man/asciinema.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 23cee46..09a970c 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -316,7 +316,7 @@ BUGS See GitHub Issues: <https://github.com/asciinema/asciinema/issues> -MORE RESSOURCES +MORE RESOURCES =============== More documentation is available on the asciicast.org website and its GitHub wiki: From d6557c76ef82b181810e981ca77cf8dd1ad38b43 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 15:48:03 +0200 Subject: [PATCH 47/65] Fix encoding of basic auth header (fixes #364) --- asciinema/urllib_http_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py index 2d1a5c3..796a3cf 100644 --- a/asciinema/urllib_http_adapter.py +++ b/asciinema/urllib_http_adapter.py @@ -67,7 +67,7 @@ class URLLibHttpAdapter: if password: auth = "%s:%s" % (username, password) - encoded_auth = base64.encodebytes(auth.encode('utf-8'))[:-1] + encoded_auth = base64.b64encode(bytes(auth, "utf-8")) headers["Authorization"] = b"Basic " + encoded_auth request = Request(url, data=body, headers=headers, method="POST") From 80c9d3dbdc7c490d4b977e007181f6266f331f77 Mon Sep 17 00:00:00 2001 From: Martin Tournoij <martin@arp242.net> Date: Sun, 6 Dec 2020 14:31:33 +0800 Subject: [PATCH 48/65] Use os.get_terminal_size() tput is part of ncurses, which may not be installed. It still falls back to tput for older Python versions (this was introduced in 3.3) or for platforms which may not support it. Fixes #418 --- asciinema/term.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/asciinema/term.py b/asciinema/term.py index c1b6dfa..f3b54fb 100644 --- a/asciinema/term.py +++ b/asciinema/term.py @@ -33,8 +33,10 @@ def read_blocking(fd, timeout): def get_size(): - # TODO maybe use os.get_terminal_size ? - return ( - int(subprocess.check_output(['tput', 'cols'])), - int(subprocess.check_output(['tput', 'lines'])) - ) + try: + return os.get_terminal_size() + except: + return ( + int(subprocess.check_output(['tput', 'cols'])), + int(subprocess.check_output(['tput', 'lines'])) + ) From a05562f1e9987f00d018c876a4b4c2c6c82dafce Mon Sep 17 00:00:00 2001 From: gpotter2 <gabriel@potter.fr> Date: Fri, 27 Nov 2020 15:07:13 +0100 Subject: [PATCH 49/65] Move to GithubCI & Twine check --- .github/workflows/asciinema.yml | 44 +++++++++++++++++++++++++++++++++ .travis.yml | 17 ------------- 2 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/asciinema.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml new file mode 100644 index 0000000..475bb50 --- /dev/null +++ b/.github/workflows/asciinema.yml @@ -0,0 +1,44 @@ +name: Asciinema unit tests + +on: [push, pull_request] + +jobs: + # Code style checks + health: + name: Code health check + runs-on: ubuntu-latest + steps: + - name: Checkout Asciinema + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: pip install pycodestyle twine setuptools>=38.6.0 cmarkgfm + - name: Run pycodestyle + run: find . -name \*.py -exec pycodestyle --ignore=E501,E402,E722 {} + + - name: Run twine + run: | + python setup.py --quiet sdist + twine check dist/* + # Asciinema checks + asciinema: + name: Asciinema - py${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: [3.4, 3.5, 3.6, 3.7, 3.8, 3.9] + env: + TERM: dumb + steps: + - name: Checkout Asciinema + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: python setup.py test + - name: Run Asciinema tests + run: script -e -c make test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 86c16cd..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -sudo: required -dist: xenial -language: python - -python: - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8-dev" - -before_install: - - pip install pycodestyle - -script: - - find . -name \*.py -exec pycodestyle --ignore=E501,E402,E722 {} + - - make test From f5e70f9c9ae4f04aa3618468ce00f20ad66a3af2 Mon Sep 17 00:00:00 2001 From: gpotter2 <gabriel@potter.fr> Date: Fri, 27 Nov 2020 15:34:16 +0100 Subject: [PATCH 50/65] setup.cfg: add LICENSE --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3af89fa..bbd4d8d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] description-file = README.md - +license_file = LICENSE [pycodestyle] ignore = E501,E402,E722 From b906a2a4d318953498a6efc98071487919048e64 Mon Sep 17 00:00:00 2001 From: gpotter2 <gabriel@potter.fr> Date: Fri, 27 Nov 2020 15:43:51 +0100 Subject: [PATCH 51/65] Use twine instead of setup.py sdist upload "python setup.py sdist upload" is deprecated and should not be used. It also doesn't support nice and fancy markdown (this is why https://pypi.org/project/asciinema/2.0.2/ looks like garbage). See https://packaging.python.org/guides/distributing-packages-using-setuptools/ --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9429656..f26485a 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,14 @@ tag: git push --tags push: - python3 setup.py sdist upload -r pypi + python3 -m pip install --user --upgrade --quiet twine + python3 setup.py sdist bdist_wheel + python3 -m twine upload dist/* push-test: - python3 setup.py sdist upload -r pypitest + python3 -m pip install --user --upgrade --quiet twine + python3 setup.py sdist bdist_wheel + python3 -m twine upload --repository testpypi dist/* release: test tag push From 44c782dc6a5752a489df6898353c783ae8a56ea9 Mon Sep 17 00:00:00 2001 From: gpotter2 <gabriel@potter.fr> Date: Fri, 27 Nov 2020 15:47:15 +0100 Subject: [PATCH 52/65] test_require is deprecated --- .github/workflows/asciinema.yml | 2 +- setup.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 475bb50..d94a3e9 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -39,6 +39,6 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install dependencies - run: python setup.py test + run: pip install nose - name: Run Asciinema tests run: script -e -c make test diff --git a/setup.py b/setup.py index d40539c..e7d3ef3 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,6 @@ if sys.version_info.major < 3: url_template = 'https://github.com/asciinema/asciinema/archive/v%s.tar.gz' requirements = [] -test_requirements = ['nose'] with open('README.md', encoding='utf8') as file: long_description = file.read() @@ -38,7 +37,6 @@ setup( 'doc/asciicast-v2.md']), ('share/man/man1', ['man/asciinema.1'])], install_requires=requirements, - tests_require=test_requirements, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', From 65559c207ee507b7520494e0c7e1a642fc03ca8b Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 16:38:01 +0200 Subject: [PATCH 53/65] Don't test on EOL-ed Python versions (3.4 and 3.5) --- .github/workflows/asciinema.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index d94a3e9..c419398 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.4, 3.5, 3.6, 3.7, 3.8, 3.9] + python: [3.6, 3.7, 3.8, 3.9] env: TERM: dumb steps: From 9910f6c8634c39f0feb508ad3f6c1c53da07f925 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 16:44:28 +0200 Subject: [PATCH 54/65] Update build status badge --- .github/workflows/asciinema.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index c419398..ac35dc5 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -1,4 +1,4 @@ -name: Asciinema unit tests +name: build on: [push, pull_request] diff --git a/README.md b/README.md index dde7c3e..f6d151d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _Note: This is README for `development` branch. [See the version for latest stab # asciinema -[![Build Status](https://travis-ci.org/asciinema/asciinema.svg?branch=develop)](https://travis-ci.org/asciinema/asciinema) +[![Build Status](https://github.com/asciinema/asciinema/actions/workflows/asciinema.yml/badge.svg)](https://github.com/asciinema/asciinema/actions/workflows/asciinema.yml) [![PyPI](https://img.shields.io/pypi/v/asciinema.svg)](https://pypi.org/project/asciinema/) [![license](http://img.shields.io/badge/license-GNU-blue.svg)](https://raw.githubusercontent.com/asciinema/asciinema/master/LICENSE) From 6e34c643838acd550c36695d80f41a55dc8fd76f Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:09:59 +0200 Subject: [PATCH 55/65] Upgrade Docker image to use Ubuntu 20.04 as a base --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b70c6b5..cea3fe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:19.04 +FROM ubuntu:20.04 RUN apt-get update && apt-get install -y \ ca-certificates \ From fcec36cb512c9e9732c22f03e217debdbd8dd2a8 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:14:53 +0200 Subject: [PATCH 56/65] Remove duplicate make task --- Makefile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Makefile b/Makefile index f26485a..94c9351 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,4 @@ push-test: python3 setup.py sdist bdist_wheel python3 -m twine upload --repository testpypi dist/* -release: test tag push - .PHONY: test test-unit test-integration release release-test tag push push-test From 1330940de27d083055e3865a964ba18dbf013e60 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:16:09 +0200 Subject: [PATCH 57/65] Don't push all tags - just the new release one --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 94c9351..747b7c2 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ release-test: test push-test tag: git tag | grep "v$(VERSION)" && echo "Tag v$(VERSION) exists" && exit 1 || true git tag -s -m "Releasing $(VERSION)" v$(VERSION) - git push --tags + git push origin v$(VERSION) push: python3 -m pip install --user --upgrade --quiet twine From 4cfc26661c5e169b3d125dd8b1a0e702cc53b12a Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:20:15 +0200 Subject: [PATCH 58/65] Bump version --- asciinema/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 3d006a5..ee1eea4 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -1,7 +1,7 @@ import sys __author__ = 'Marcin Kulik' -__version__ = '2.0.2' +__version__ = '2.1.0' if sys.version_info[0] < 3: raise ImportError('Python < 3 is unsupported.') From b28354b8d4639a91d3536b80d450d717d6613339 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:24:10 +0200 Subject: [PATCH 59/65] Update year in copyright notice --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6d151d..10ec142 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,6 @@ source [contributors](https://github.com/asciinema/asciinema/contributors). ## License -Copyright © 2011–2019 Marcin Kulik. +Copyright © 2011–2021 Marcin Kulik. All code is licensed under the GPL, v3 or later. See LICENSE file for details. From 6fefeec58590917268f52fa021a51caea7e196eb Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 20:58:24 +0200 Subject: [PATCH 60/65] Update README with regards to keyboard shortcuts --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 10ec142..5808219 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,10 @@ By running `asciinema rec [filename]` you start a new recording session. The command (process) that is recorded can be specified with `-c` option (see below), and defaults to `$SHELL` which is what you want in most cases. -You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. +You can temporarily pause recording of terminal by pressing <kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during the recording session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+P</kbd> again. +<kbd>Ctrl+\</kbd> again. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when @@ -188,12 +188,15 @@ __Replay recorded asciicast in a terminal.__ This command replays given asciicast (as recorded by `rec` command) directly in your terminal. -Following keyboard shortcuts are available: +Following keyboard shortcuts are available by default: - <kbd>Space</kbd> - toggle pause, - <kbd>.</kbd> - step through a recording a frame at a time (when paused), - <kbd>Ctrl+C</kbd> - exit. +See "Configuration file" section for information on how to customize the +keyboard shortcuts. + Playing from a local file: asciinema play /path/to/asciicast.cast @@ -330,7 +333,7 @@ yes = true quiet = true ; Define hotkey for pausing recording (suspending capture of output), -; default: C-\ +; default: C-\ (control + backslash) pause_key = C-p ; Define hotkey prefix key - when defined other recording hotkeys must @@ -351,7 +354,7 @@ pause_key = p ; Define hotkey for stepping through playback, a frame at a time, ; default: . -pause_key = ] +step_key = ] [notifications] From a97117f3c44d8ed9f27e1680b0ffb5c34c4d95eb Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 21:12:35 +0200 Subject: [PATCH 61/65] Better doc on desktop notifications --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5808219..84a8e65 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,8 @@ below), and defaults to `$SHELL` which is what you want in most cases. You can temporarily pause recording of terminal by pressing <kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during the recording session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+\</kbd> again. +<kbd>Ctrl+\</kbd> again. When pausing desktop notification is displayed so +you're sure the sensitive output is not captured in the recording. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when @@ -357,12 +358,18 @@ pause_key = p step_key = ] [notifications] +; Desktop notifications are displayed on certain occasions, e.g. when +; pausing/resuming the capture of terminal with C-\ keyboard shortcut. ; Should desktop notifications be enabled, default: yes enabled = no ; Custom notification command -; Environment variable $TEXT contains notification text +; asciinema automatically detects available desktop notification system +; (notify-send on GNU/Linux, osacript/terminal-notifier on macOS). Custom +; command can be used if needed. +; When invoked, environment variable $TEXT contains notification text, while +; $ICON_PATH contains path to the asciinema logo image. command = tmux display-message "$TEXT" ``` From f342838528f9b64a1472bbf90114fb1be0494d62 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 21:13:07 +0200 Subject: [PATCH 62/65] Update changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f686650..8d50f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # asciinema changelog +## 2.1.0 (2021-10-02) + +* Ability to pause/resume terminal capture with `C-\` key shortcut +* Desktop notifications - only for the above pause feature at the moment +* Removed dependecy on tput/ncurses (thanks @arp242 / Martin Tournoij!) +* ASCIINEMA_REC env var is back (thanks @landonb / Landon Bouma!) +* Terminal answerbacks (CSI 6 n) in `asciinema cat` are now hidden (thanks @djpohly / Devin J. Pohly!) +* Codeset detection works on HP-UX now (thanks @michael-o / Michael Osipov!) +* Attempt at recording to existing file suggests use of `--overwrite` option now +* Upload for users with very long `$USER` is fixed +* Added official support for Python 3.8 and 3.9 +* Dropped official support for EOL-ed Python 3.4 and 3.5 +* Internal refactorings + ## 2.0.2 (2019-01-12) * Official support for Python 3.7 From 666c88a782c119bd21d08c3bef8fa08649f6e3f6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 21:14:45 +0200 Subject: [PATCH 63/65] Fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d50f60..0b7dacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * Ability to pause/resume terminal capture with `C-\` key shortcut * Desktop notifications - only for the above pause feature at the moment -* Removed dependecy on tput/ncurses (thanks @arp242 / Martin Tournoij!) +* Removed dependency on tput/ncurses (thanks @arp242 / Martin Tournoij!) * ASCIINEMA_REC env var is back (thanks @landonb / Landon Bouma!) * Terminal answerbacks (CSI 6 n) in `asciinema cat` are now hidden (thanks @djpohly / Devin J. Pohly!) * Codeset detection works on HP-UX now (thanks @michael-o / Michael Osipov!) From a31326e17cc70c326dfb6bd5aa3033957a6419f3 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 21:36:14 +0200 Subject: [PATCH 64/65] Update IRC info --- CONTRIBUTING.md | 4 +++- man/asciinema.1.md | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7be73a..c7fb513 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,9 @@ If you want to propose code change, either introducing a new feature or improvin ## Asking for help Github issue tracker is not a support forum. -If you need help then either join #asciinema IRC channel on freenode or drop us an email at support@asciinema.org. + +If you need help then either join #asciinema IRC channel on libera.chat or drop +us an email at support@asciinema.org. ## Reporting security issues diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 09a970c..9737cec 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -322,8 +322,7 @@ MORE RESOURCES More documentation is available on the asciicast.org website and its GitHub wiki: * Web: [asciinema.org/docs/](https://asciinema.org/docs/) -* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) -* IRC: [Channel on Freenode](https://webchat.freenode.net/?channels=asciinema) +* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) * Twitter: [@asciinema](https://twitter.com/asciinema) From 7bebc41a820115a073c234bd41b673d2c9c6fa0a Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 2 Oct 2021 21:38:19 +0200 Subject: [PATCH 65/65] Words --- README.md | 11 ++++++----- man/asciinema.1.md | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 84a8e65..ff47317 100644 --- a/README.md +++ b/README.md @@ -137,11 +137,12 @@ By running `asciinema rec [filename]` you start a new recording session. The command (process) that is recorded can be specified with `-c` option (see below), and defaults to `$SHELL` which is what you want in most cases. -You can temporarily pause recording of terminal by pressing <kbd>Ctrl+\</kbd>. -This is useful when you want to execute some commands during the recording -session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+\</kbd> again. When pausing desktop notification is displayed so -you're sure the sensitive output is not captured in the recording. +You can temporarily pause the capture of your terminal by pressing +<kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during +the recording session that should not be captured (e.g. pasting secrets). Resume +by pressing <kbd>Ctrl+\</kbd> again. When pausing desktop notification is +displayed so you're sure the sensitive output won't be captured in the +recording. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 9737cec..2687c08 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -42,10 +42,12 @@ By running **asciinema rec [filename]** you start a new recording session. The command (process) that is recorded can be specified with **-c** option (see below), and defaults to **$SHELL** which is what you want in most cases. -You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. -This is useful when you want to execute some commands during the recording -session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+P</kbd> again. +You can temporarily pause the capture of your terminal by pressing +<kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during +the recording session that should not be captured (e.g. pasting secrets). Resume +by pressing <kbd>Ctrl+\</kbd> again. When pausing desktop notification is +displayed so you're sure the sensitive output won't be captured in the +recording. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when