mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-15 19:28:00 +01:00
Merge branch 'release/2.1.0'
This commit is contained in:
44
.github/workflows/asciinema.yml
vendored
Normal file
44
.github/workflows/asciinema.yml
vendored
Normal 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
|
||||
17
.travis.yml
17
.travis.yml
@@ -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
|
||||
14
CHANGELOG.md
14
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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
12
Makefile
12
Makefile
@@ -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
105
README.md
@@ -1,6 +1,6 @@
|
||||
# asciinema
|
||||
|
||||
[](https://travis-ci.org/asciinema/asciinema)
|
||||
[](https://github.com/asciinema/asciinema/actions/workflows/asciinema.yml)
|
||||
[](https://pypi.org/project/asciinema/)
|
||||
[](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 © 2011–2018 Marcin Kulik.
|
||||
Copyright © 2011–2021 Marcin Kulik.
|
||||
|
||||
All code is licensed under the GPL, v3 or later. See LICENSE file for details.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
31
asciinema/async_worker.py
Normal 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)
|
||||
@@ -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 '
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
BIN
asciinema/data/icon-256x256.png
Normal file
BIN
asciinema/data/icon-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
83
asciinema/notifier.py
Normal file
83
asciinema/notifier.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
102
asciinema/recorder.py
Normal 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
|
||||
@@ -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']))
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
:
|
||||
:
|
||||
|
||||
`--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:
|
||||
|
||||
:
|
||||
:
|
||||
|
||||
`-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 +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 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 +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`*:
|
||||
|
||||
:
|
||||
|
||||
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 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.
|
||||
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
[metadata]
|
||||
description-file = README.md
|
||||
license_file = LICENSE
|
||||
|
||||
[pycodestyle]
|
||||
ignore = E501,E402,E722
|
||||
|
||||
5
setup.py
5
setup.py
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user