Merge branch 'release/2.1.0'

This commit is contained in:
Marcin Kulik
2021-10-02 21:42:45 +02:00
32 changed files with 707 additions and 333 deletions

44
.github/workflows/asciinema.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: build
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.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: pip install nose
- name: Run Asciinema tests
run: script -e -c make test

View File

@@ -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 {} +
- make test

View File

@@ -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 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!)
* 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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
FROM ubuntu:16.04
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y \
ca-certificates \
@@ -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
@@ -17,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"]

View File

@@ -16,14 +16,16 @@ 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 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
release: test tag push
python3 -m pip install --user --upgrade --quiet twine
python3 setup.py sdist bdist_wheel
python3 -m twine upload --repository testpypi dist/*
.PHONY: test test-unit test-integration release release-test tag push push-test

105
README.md
View File

@@ -1,6 +1,6 @@
# asciinema
[![Build Status](https://travis-ci.org/asciinema/asciinema.svg?branch=master)](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)
@@ -71,34 +71,10 @@ 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).
### Docker image
asciinema Docker image is based on Ubuntu 16.04 and has the latest version of
asciinema recorder pre-installed.
docker pull asciinema/asciinema
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
Default command run in a container is `asciinema rec`.
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`:
docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema /bin/bash
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.
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:
@@ -117,6 +93,32 @@ Then run it with:
python3 -m asciinema --version
### Docker image
asciinema Docker image is based on Ubuntu 18.04 and has the latest version of
asciinema recorder pre-installed.
docker pull asciinema/asciinema
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 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 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
root@6689517d99a1:~# apt-get install foobar
root@6689517d99a1:~# asciinema rec
## Usage
asciinema is composed of multiple commands, similar to `git`, `apt-get` or
@@ -133,6 +135,13 @@ 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 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
the process exits.
@@ -179,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
@@ -254,7 +266,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.
@@ -282,7 +294,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
@@ -320,6 +332,14 @@ yes = true
; Be quiet, suppress all notices/warnings, default: no
quiet = true
; Define hotkey for pausing recording (suspending capture of output),
; default: C-\ (control + backslash)
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
@@ -327,6 +347,29 @@ 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: .
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
; 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"
```
A very minimal config file could look like that:
@@ -357,6 +400,6 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
## License
Copyright &copy; 20112018 Marcin Kulik.
Copyright &copy; 20112021 Marcin Kulik.
All code is licensed under the GPL, v3 or later. See LICENSE file for details.

View File

@@ -1,58 +1,25 @@
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.')
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
)

View File

@@ -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,36 +20,13 @@ 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)
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)
@@ -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:

View File

@@ -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_stdin(self, data):
def write_stdout(self, ts, data):
self.file.write(data)
def write_stdin(self, ts, data):
pass
def write_stdout(self, data):
self.queue.put(data)

View File

@@ -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,20 +82,18 @@ def build_header(metadata):
class writer():
def __init__(self, path, width=None, height=None, header=None, mode='w', buffering=-1):
def __init__(self, path, metadata=None, append=False, buffering=1, width=None, height=None):
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':
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:
if append:
self.mode = 'a'
self.header = None
else:
self.mode = 'w'
self.header = build_header(width, height, metadata or {})
def __enter__(self):
self.file = open(self.path, mode=self.mode, buffering=self.buffering)
@@ -120,73 +106,21 @@ 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=(', ', ': '))
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:
for event in iter(queue.get, None):
w.write_event(event)
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)
mode = 'a' if self.append else 'w'
self.process = Process(
target=write_json_lines_from_queue,
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
self.queue.put([ts, 'i', data])
def write_stdout(self, data):
ts = time.time() - self.start_time
self.queue.put([ts, 'o', data])

31
asciinema/async_worker.py Normal file
View File

@@ -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)

View File

@@ -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 '

View File

@@ -1,21 +1,24 @@
import sys
from asciinema.commands.command import Command
from asciinema.term import raw
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:
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))

View File

@@ -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:

View File

@@ -5,17 +5,21 @@ 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()
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))

View File

@@ -2,18 +2,19 @@ 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
import asciinema.notifier as notifier
from asciinema.api import APIError
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
@@ -24,8 +25,13 @@ 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.env = env if env is not None else os.environ
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
@@ -50,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
@@ -66,7 +73,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,
@@ -75,7 +82,9 @@ class RecordCommand(Command):
command_env=self.env,
capture_env=vars,
rec_stdin=self.rec_stdin,
writer=self.writer
writer=self.writer,
notifier=self.notifier,
key_bindings=self.key_bindings
)
except v2.LoadError:
self.print_error("can only append to asciicast v2 format recordings")

View File

@@ -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:

View File

@@ -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
@@ -118,6 +126,36 @@ 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)
@property
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, 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('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')
def get_config_home(env=os.environ):
env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

83
asciinema/notifier.py Normal file
View File

@@ -0,0 +1,83 @@
import os.path
import shutil
import subprocess
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
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"
def args(self, text):
text = text.replace('"', '\\"')
return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)]
class LibNotifyNotifier(Notifier):
cmd = "notify-send"
def args(self, 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):
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 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(enabled=True, command=None):
if enabled:
if command:
return CustomCommandNotifier(command)
else:
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
n = c()
if n.is_available():
return n
return NoopNotifier()

View File

@@ -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)

View File

@@ -10,12 +10,22 @@ 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):
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:
notifier.notify(text)
def _set_pty_size():
'''
@@ -40,7 +50,9 @@ 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)
if not pause_time:
writer.write_stdout(time.time() - start_time, data)
_write_stdout(data)
def _write_master(data):
@@ -53,10 +65,32 @@ def record(command, writer, env=os.environ, rec_stdin=False):
def _handle_stdin_read(data):
'''Handles new data on child process stdin.'''
nonlocal pause_time
nonlocal start_time
nonlocal prefix_mode
if not prefix_mode and prefix_key and data == prefix_key:
prefix_mode = True
return
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:
writer.write_stdin(data)
if rec_stdin and not pause_time:
writer.write_stdin(time.time() - start_time, data)
def _signals(signal_list):
old_handlers = []
@@ -129,6 +163,8 @@ def record(command, writer, env=os.environ, rec_stdin=False):
_set_pty_size()
start_time = time.time() - time_offset
with raw(pty.STDIN_FILENO):
try:
_copy(pipe_r)

102
asciinema/recorder.py Normal file
View File

@@ -0,0 +1,102 @@
import os
import time
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,
rec_stdin=False, title=None, metadata=None, command_env=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'
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) as w:
with async_notifier(notifier) as n:
record(
['sh', '-c', command],
w,
command_env,
rec_stdin,
time_offset,
n,
key_bindings
)
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
def write_stdin(self, ts, data):
self.enqueue([ts, 'i', data])
def write_stdout(self, ts, 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)
class async_notifier(async_worker):
def __init__(self, notifier):
async_worker.__init__(self)
self.notifier = notifier
def notify(self, text):
self.enqueue(text)
def perform(self, text):
try:
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
pass

View File

@@ -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.01)
tty.tcsetattr(self.fd, tty.TCSAFLUSH, self.mode)
@@ -30,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']))
)

View File

@@ -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")

View File

@@ -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`"

View File

@@ -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_]
---
@@ -35,6 +42,13 @@ 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 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
the process exits.
@@ -55,37 +69,37 @@ prompt or play a sound when the shell is being recorded.
Available options:
: &nbsp;
: &nbsp;
`--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=<command>`
: Specify command to record, defaults to **$SHELL**
: Specify command to record, defaults to **$SHELL**
`-e, --env=<var-names>`
: List of environment variables to capture, defaults to **SHELL,TERM**
: List of environment variables to capture, defaults to **SHELL,TERM**
`-t, --title=<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 +108,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 +143,32 @@ Playing from IPFS:
Available options:
: &nbsp;
: &nbsp;
`-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:
: &nbsp;
*`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 +181,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 +195,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 +215,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 youre
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, thats 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 +235,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 +267,67 @@ 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`*:
: &nbsp;
cat /path/to/asciicast.json | asciinema play -
Play file from remote host accessible with SSH:
: &nbsp;
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 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)
* Twitter: [@asciinema](https://twitter.com/asciinema)
AUTHORS
=======
@@ -255,3 +336,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.

View File

@@ -1,2 +1,6 @@
[metadata]
description-file = README.md
license_file = LICENSE
[pycodestyle]
ignore = E501,E402,E722

View File

@@ -2,12 +2,11 @@ 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'
requirements = []
test_requirements = ['nose']
with open('README.md', encoding='utf8') as file:
long_description = file.read()
@@ -29,6 +28,7 @@ setup(
'asciinema = asciinema.__main__:main',
],
},
package_data={'asciinema': ['data/*.png']},
data_files=[('share/doc/asciinema', ['CHANGELOG.md',
'CODE_OF_CONDUCT.md',
'CONTRIBUTING.md',
@@ -37,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',

View File

@@ -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)

View File

@@ -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