Merge branch 'develop'

This commit is contained in:
Marcin Kulik
2022-05-07 20:33:28 +02:00
55 changed files with 2527 additions and 1432 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
---
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

View File

@@ -1,44 +1,110 @@
---
name: build
on: [push, pull_request]
on:
- push
- pull_request
jobs:
# Code style checks
health:
name: Code health check
name: code health check
runs-on: ubuntu-latest
steps:
- name: Checkout Asciinema
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
- name: checkout asciinema
uses: actions/checkout@v3
- name: setup Python
uses: actions/setup-python@v3
with:
python-version: 3.9
- name: Install dependencies
run: pip install pycodestyle twine setuptools>=38.6.0 cmarkgfm
python-version: "3.10"
- name: install dependencies
run: pip install build cmarkgfm pycodestyle twine
- name: Run pycodestyle
run: find . -name \*.py -exec pycodestyle --ignore=E501,E402,E722 {} +
run: >
find .
-name '*\.py'
-exec pycodestyle --ignore=E402,E501,E722,W503 "{}" \+
- name: Run twine
run: |
python setup.py --quiet sdist
python3 -m build
twine check dist/*
# Asciinema checks
asciinema:
name: Asciinema - py${{ matrix.python }}
name: Asciinema
runs-on: ubuntu-latest
strategy:
matrix:
python: [3.6, 3.7, 3.8, 3.9]
python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
- "3.10"
env:
TERM: dumb
steps:
- name: Checkout Asciinema
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
- name: checkout Asciinema
uses: actions/checkout@v3
- name: setup Python
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: pip install nose
- name: Run Asciinema tests
- name: install dependencies
run: pip install pytest
- name: run Asciinema tests
run: script -e -c make test
build_distros:
name: build distro images
strategy:
matrix:
distros:
- alpine
- arch
- centos
- debian
- fedora
- ubuntu
runs-on: ubuntu-latest
steps:
- name: Set up Docker buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Authenticate to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: "${{ github.actor }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: "Build ${{ matrix.distros }} image"
uses: docker/build-push-action@v3
with:
file: "tests/distros/Dockerfile.${{ matrix.distros }}"
tags: |
"ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
push: true
test_distros:
name: integration test distro images
needs: build_distros
strategy:
matrix:
distros:
- alpine
- arch
- centos
- debian
- fedora
- ubuntu
runs-on: ubuntu-latest
container:
image: "ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
credentials:
username: "${{ github.actor }}"
password: "${{ secrets.GITHUB_TOKEN }}"
# https://github.community/t/permission-problems-when-checking-out-code-as-part-of-github-action/202263
options: "--interactive --tty --user=1001:121"
steps:
- name: checkout Asciinema
uses: actions/checkout@v3
- name: run integration tests
env:
TERM: dumb
shell: 'script --return --quiet --command "bash {0}"'
run: make test.integration

14
.github/workflows/pre-commit.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
---
name: pre-commit
on:
- pull_request
- push
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.10"
- uses: pre-commit/action@v2.0.3

39
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,39 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: requirements-txt-fixer
- id: trailing-whitespace
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
- repo: https://github.com/adrienverge/yamllint
rev: v1.26.3
hooks:
- id: yamllint
- repo: https://github.com/myint/autoflake
rev: v1.4
hooks:
- id: autoflake
args:
- --in-place
- --recursive
- --expand-star-imports
- --remove-all-unused-imports
- --remove-duplicate-keys
- --remove-unused-variables
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v0.931"
hooks:
- id: mypy

View File

@@ -1,5 +1,19 @@
# asciinema changelog
## 2.2.0 (2022-05-07)
* Added official support for Python 3.8, 3.9, 3.10
* Dropped official support for Python 3.5
* Added `--cols` / `--rows` options for overriding size of pseudo-terminal reported to recorded program
* Improved behaviour of `--append` when output file doesn't exist
* Keyboard input is now explicitly read from a TTY device in addition to stdin (when stdin != TTY)
* Recorded program output is now explicitly written to a TTY device instead of stdout
* Dash char (`-`) can now be passed as output filename to write asciicast to stdout
* Diagnostic messages are now printed to stderr (without colors when stderr != TTY)
* Improved robustness of writing asciicast to named pipes
* Lots of codebase modernizations (many thanks to Davis @djds Schirmer!)
* Many other internal refactorings
## 2.1.0 (2021-10-02)
* Ability to pause/resume terminal capture with `C-\` key shortcut
@@ -12,7 +26,6 @@
* 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)

View File

@@ -1,49 +1,64 @@
# Contributing to asciinema
First, if you're opening a Github issue make sure it goes to the correct repository:
First, if you're opening a GitHub issue make sure it goes to the correct
repository:
* [asciinema/asciinema](https://github.com/asciinema/asciinema/issues) - command-line recorder
* [asciinema/asciinema-server](https://github.com/asciinema/asciinema-server/issues) - public website hosting recordings
* [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player/issues) - player
- [asciinema/asciinema](https://github.com/asciinema/asciinema/issues) - command-line recorder
- [asciinema/asciinema-server](https://github.com/asciinema/asciinema-server/issues) - public website hosting recordings
- [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player/issues) - player
## Reporting bugs
Open an issue in Github issue tracker.
Open an issue in GitHub issue tracker.
Tell us what's the problem and include steps to reproduce it (reliably).
Including your OS/browser/terminal name and version in the report would be great.
Including your OS/browser/terminal name and version in the report would be
great.
## Submitting patches with bug fixes
If you found a bug and made a patch for it:
* make sure all tests pass
* send us a pull request, including a description of the fix (referencing an existing issue if there's one)
1. Make sure your changes pass the [pre-commit](https://pre-commit.com/)
[hooks](.pre-commit-config.yaml). You can install the hooks in your work
tree by running `pre-commit install` in your checked out copy.
1. Make sure all tests pass. If you add new functionality, add new tests.
1. Send us a pull request, including a description of the fix (referencing an
existing issue if there's one).
## Requesting new features
We welcome all ideas.
If you believe most asciinema users would benefit from implementing your idea then feel free to open a Github issue.
However, as this is an open-source project maintained by a small team of volunteers we simply can't implement all of them due to limited resources. Please keep that in mind.
If you believe most asciinema users would benefit from implementing your idea
then feel free to open a GitHub issue. However, as this is an open-source
project maintained by a small team of volunteers we simply can't implement all
of them due to limited resources. Please keep that in mind.
## Proposing features/changes (pull requests)
If you want to propose code change, either introducing a new feature or improving an existing one, please first discuss this with asciinema team. You can simply open a separate issue for a discussion or join #asciinema IRC channel on freenode.
If you want to propose code change, either introducing a new feature or
improving an existing one, please first discuss this with asciinema team. You
can simply open a separate issue for a discussion or join #asciinema IRC
channel on Libera.Chat.
## Asking for help
Github issue tracker is not a support forum.
GitHub issue tracker is not a support forum.
If you need help then either join #asciinema IRC channel on libera.chat 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
If you found a security issue in asciinema please contact us at support@asciinema.org.
For the benefit of all asciinema users please **do not** publish details of the vulnerability in a Github issue.
If you found a security issue in asciinema please contact us at
support@asciinema.org. For the benefit of all asciinema users please **do
not** publish details of the vulnerability in a GitHub issue.
The PGP key below (1eb33a8760dec34b) can be used when sending encrypted email to or verifying responses from support@asciinema.org.
The PGP key below (1eb33a8760dec34b) can be used when sending encrypted email
to or verifying responses from support@asciinema.org.
```
```Public Key
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v2

View File

@@ -1,22 +1,38 @@
FROM ubuntu:20.04
# syntax=docker/dockerfile:1.3
RUN apt-get update && apt-get install -y \
ca-certificates \
locales \
python3 \
python3-setuptools
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 *.md /usr/src/app/
FROM docker.io/library/ubuntu:20.04
ENV DEBIAN_FRONTEND="noninteractive"
RUN apt-get update \
&& apt-get install -y \
ca-certificates \
locales \
python3 \
python3-pip \
&& localedef \
-i en_US \
-c \
-f UTF-8 \
-A /usr/share/locale/locale.alias \
en_US.UTF-8
COPY pyproject.toml setup.cfg *.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
COPY asciinema/ /usr/src/app/asciinema/
COPY README.md LICENSE /usr/src/app/
WORKDIR /usr/src/app
RUN python3 setup.py install
ENV LANG en_US.utf8
ENV SHELL /bin/bash
ENV USER docker
RUN pip3 install .
WORKDIR /root
ENV LANG="en_US.utf8"
ENV SHELL="/bin/bash"
ENTRYPOINT ["/usr/local/bin/asciinema"]
CMD ["--help"]
# vim:ft=dockerfile

View File

@@ -1,31 +1,75 @@
NAME=asciinema
VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"`
NAME := asciinema
VERSION := $(shell python3 -c "import asciinema; print(asciinema.__version__)")
test: test-unit test-integration
VIRTUAL_ENV ?= .venv
test-unit:
nosetests
.PHONY: test
test: test.unit test.integration
test-integration:
.PHONY: test.unit
test.unit:
pytest
.PHONY: test.integration
test.integration:
tests/integration.sh
.PHONY: test.distros
test.distros:
tests/distros.sh
.PHONY: release
release: test tag push
release-test: test push-test
.PHONY: release.test
release.test: test push.test
tag:
git tag | grep "v$(VERSION)" && echo "Tag v$(VERSION) exists" && exit 1 || true
.PHONY: .tag.exists
.tag.exists:
@git tag \
| grep -q "v$(VERSION)" \
&& echo "Tag v$(VERSION) exists" \
&& exit 1
.PHONY: tag
tag: .tag.exists
git tag -s -m "Releasing $(VERSION)" v$(VERSION)
git push origin v$(VERSION)
push:
python3 -m pip install --user --upgrade --quiet twine
python3 setup.py sdist bdist_wheel
.PHONY: .venv
.venv:
python3 -m venv $(VIRTUAL_ENV)
.PHONY: .pip
.pip: .venv
. $(VIRTUAL_ENV)/bin/activate \
&& python3 -m pip install --upgrade build twine
build: .pip
. $(VIRTUAL_ENV)/bin/activate \
&& python3 -m build .
install: build
. $(VIRTUAL_ENV)/bin/activate \
&& python3 -m pip install .
.PHONY: push
push: .pip build
python3 -m twine upload dist/*
push-test:
python3 -m pip install --user --upgrade --quiet twine
python3 setup.py sdist bdist_wheel
.PHONY: push.test
push.test: .pip build
python3 -m twine upload --repository testpypi dist/*
.PHONY: test test-unit test-integration release release-test tag push push-test
.PHONY: clean
clean:
rm -rf dist *.egg-info
clean.all: clean
find . \
-type d \
-name __pycache__ \
-o -name .pytest_cache \
-o -name .mypy_cache \
-exec rm -r "{}" +

180
README.md
View File

@@ -14,21 +14,36 @@ Terminal session recorder and the best companion of
asciinema lets you easily record terminal sessions and replay
them in a terminal as well as in a web browser.
Install latest version ([other installation options](#installation)):
Install latest version ([other installation options](#installation))
using [pipx](https://pypa.github.io/pipx/) (if you have it):
sudo pip3 install asciinema
```sh
pipx install asciinema
```
If you don't have pipx, install using pip with your preferred Python version:
```sh
python3 -m pip install asciinema
```
Record your first session:
asciinema rec first.cast
```sh
asciinema rec first.cast
```
Now replay it with double speed:
asciinema play -s 2 first.cast
```sh
asciinema play -s 2 first.cast
```
Or with normal speed but with idle time limited to 2 seconds:
asciinema play -i 2 first.cast
```sh
asciinema play -i 2 first.cast
```
You can pass `-i 2` to `asciinema rec` as well, to set it permanently on a
recording. Idle time limiting makes the recordings much more interesting to
@@ -36,7 +51,9 @@ watch. Try it.
If you want to watch and share it on the web, upload it:
asciinema upload first.cast
```sh
asciinema upload first.cast
```
The above uploads it to [asciinema.org](https://asciinema.org), which is a
default [asciinema-server](https://github.com/asciinema/asciinema-server)
@@ -45,7 +62,9 @@ browser.
You can record and upload in one step by omitting the filename:
asciinema rec
```sh
asciinema rec
```
You'll be asked to confirm the upload when the recording is done. Nothing is
sent anywhere without your consent.
@@ -55,15 +74,25 @@ cover installation, usage and hosting of the recordings in more detail.
## Installation
### Python package
### Python package from PyPI
asciinema is available on [PyPI](https://pypi.python.org/pypi/asciinema) and can
be installed with pip (Python 3 with setuptools required):
[pypi]: https://pypi.python.org/pypi/asciinema
sudo pip3 install asciinema
asciinema is available on [PyPI] and can be installed with
[pipx](https://pypa.github.io/pipx/) (if you have it) or with pip (Python 3
with setuptools required):
This is the recommended way of installation, which gives you the latest released
version.
```sh
pipx install asciinema
```
Or with pip (using your preferred Python version):
```sh
python3 -m pip install asciinema
```
Installing from [PyPI] is the recommended way of installation, which gives you the latest released version.
### Native packages
@@ -78,32 +107,45 @@ can clone the repo and run asciinema straight from the checkout.
Clone the repo:
git clone https://github.com/asciinema/asciinema.git
cd asciinema
```sh
git clone https://github.com/asciinema/asciinema.git
cd asciinema
```
If you want latest stable version:
git checkout master
```sh
git checkout master
```
If you want current development version:
git checkout develop
```sh
git checkout develop
```
Then run it with:
python3 -m asciinema --version
```sh
python3 -m asciinema --version
```
### Docker image
asciinema Docker image is based on Ubuntu 18.04 and has the latest version of
asciinema Docker image is based on [Ubuntu
20.04](https://releases.ubuntu.com/20.04/) and has the latest version of
asciinema recorder pre-installed.
docker pull asciinema/asciinema
```sh
docker pull docker.io/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
```sh
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" docker.io/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
@@ -111,13 +153,28 @@ 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`
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
```console
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash docker.io/asciinema/asciinema rec
root@6689517d99a1:~# apt-get install foobar
root@6689517d99a1:~# asciinema rec
```
It is also possible to run the docker container as a non-root user, which has
security benefits. You can specify a user and group id at runtime to give the
application permission similar to the calling user on your host.
```sh
docker run --rm -it \
--env=ASCIINEMA_CONFIG_HOME="/run/user/$(id -u)/.config/asciinema" \
--user="$(id -u):$(id -g)" \
--volume="${HOME}/.config/asciinema:/run/user/$(id -u)/.config/asciinema:rw" \
--volume="${PWD}:/data:rw" \
--workdir='/data' \
docker.io/asciinema/asciinema rec
```
## Usage
@@ -129,7 +186,7 @@ all available commands with their options.
### `rec [filename]`
__Record terminal session.__
**Record terminal session.**
By running `asciinema rec [filename]` you start a new recording session. The
command (process) that is recorded can be specified with `-c` option (see
@@ -162,17 +219,19 @@ prompt or play a sound when the shell is being recorded.
Available options:
* `--stdin` - Enable stdin (keyboard) recording (see below)
* `--append` - Append to existing recording
* `--raw` - Save raw STDOUT output, without timing information or other metadata
* `--overwrite` - Overwrite the recording if it already exists
* `-c, --command=<command>` - Specify command to record, defaults to $SHELL
* `-e, --env=<var-names>` - List of environment variables to capture, defaults
- `--stdin` - Enable stdin (keyboard) recording (see below)
- `--append` - Append to existing recording
- `--raw` - Save raw STDOUT output, without timing information or other metadata
- `--overwrite` - Overwrite the recording if it already exists
- `-c, --command=<command>` - Specify command to record, defaults to $SHELL
- `-e, --env=<var-names>` - List of environment variables to capture, defaults
to `SHELL,TERM`
* `-t, --title=<title>` - Specify the title of the asciicast
* `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
* `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation)
* `-q, --quiet` - Be quiet, suppress all notices/warnings (implies -y)
- `-t, --title=<title>` - Specify the title of the asciicast
- `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
- `--cols=<n>` - Override terminal columns for recorded process
- `--rows=<n>` - Override terminal rows for recorded process
- `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation)
- `-q, --quiet` - 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.
@@ -183,7 +242,7 @@ instance), it's disabled by default, and has to be explicitly enabled via
### `play <filename>`
__Replay recorded asciicast in a terminal.__
**Replay recorded asciicast in a terminal.**
This command replays given asciicast (as recorded by `rec` command) directly in
your terminal.
@@ -199,32 +258,41 @@ keyboard shortcuts.
Playing from a local file:
asciinema play /path/to/asciicast.cast
```sh
asciinema play /path/to/asciicast.cast
```
Playing from HTTP(S) URL:
asciinema play https://asciinema.org/a/22124.cast
asciinema play http://example.com/demo.cast
```sh
asciinema play https://asciinema.org/a/22124.cast
asciinema play http://example.com/demo.cast
```
Playing from asciicast page URL (requires `<link rel="alternate"
type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
Playing from asciicast page URL (requires `<link rel="alternate" type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
asciinema play https://asciinema.org/a/22124
asciinema play http://example.com/blog/post.html
```sh
asciinema play https://asciinema.org/a/22124
asciinema play http://example.com/blog/post.html
```
Playing from stdin:
cat /path/to/asciicast.cast | asciinema play -
ssh user@host cat asciicast.cast | asciinema play -
```sh
cat /path/to/asciicast.cast | asciinema play -
ssh user@host cat asciicast.cast | asciinema play -
```
Playing from IPFS:
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
```sh
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
```
Available options:
* `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
* `-s, --speed=<factor>` - Playback speed (can be fractional)
- `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
- `-s, --speed=<factor>` - Playback speed (can be fractional)
> For the best playback experience it is recommended to run `asciinema play` in
> a terminal of dimensions not smaller than the one used for recording, as
@@ -232,7 +300,7 @@ Available options:
### `cat <filename>`
__Print full output of recorded asciicast to a terminal.__
**Print full output of recorded asciicast to a terminal.**
While `asciinema play <filename>` replays the recorded session using timing
information saved in the asciicast, `asciinema cat <filename>` dumps the full
@@ -243,18 +311,17 @@ output (including all escape sequences) to a terminal immediately.
### `upload <filename>`
__Upload recorded asciicast to asciinema.org site.__
**Upload recorded asciicast to asciinema.org site.**
This command uploads given asciicast (recorded by `rec` command) to
asciinema.org, where it can be watched and shared.
`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload
demo.cast` is a nice combo if you want to review an asciicast before
`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload 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 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
@@ -402,4 +469,5 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
Copyright &copy; 20112021 Marcin Kulik.
All code is licensed under the GPL, v3 or later. See LICENSE file for details.
All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file
for details.

View File

@@ -1,25 +1,34 @@
import sys
__author__ = 'Marcin Kulik'
__version__ = '2.1.0'
__author__ = "Marcin Kulik"
__version__ = "2.2.0"
if sys.version_info[0] < 3:
raise ImportError('Python < 3 is unsupported.')
if sys.version_info < (3, 6):
raise ImportError("Python < 3.6 is unsupported.")
import asciinema.recorder
# pylint: disable=wrong-import-position
from typing import Any, Optional
from .recorder import record
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):
asciinema.recorder.record(
path,
def record_asciicast( # pylint: disable=too-many-arguments
path_: str,
command: Any = None,
append: bool = False,
idle_time_limit: Optional[int] = None,
record_stdin: bool = False,
title: Optional[str] = None,
command_env: Any = None,
capture_env: Any = None,
) -> None:
record(
path_,
command=command,
append=append,
idle_time_limit=idle_time_limit,
rec_stdin=rec_stdin,
record_stdin=record_stdin,
title=title,
metadata=metadata,
command_env=command_env,
capture_env=capture_env
capture_env=capture_env,
)

View File

@@ -1,40 +1,56 @@
import locale
import argparse
import locale
import os
import sys
from typing import Any, Optional
from asciinema import __version__
import asciinema.config as config
from asciinema.commands.auth import AuthCommand
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 . import __version__, config
from .commands.auth import AuthCommand
from .commands.cat import CatCommand
from .commands.play import PlayCommand
from .commands.record import RecordCommand
from .commands.upload import UploadCommand
def positive_float(value):
value = float(value)
if value <= 0.0:
def positive_int(value: str) -> int:
_value = int(value)
if _value <= 0:
raise argparse.ArgumentTypeError("must be positive")
return value
return _value
def maybe_str(v):
def positive_float(value: str) -> float:
_value = float(value)
if _value <= 0.0:
raise argparse.ArgumentTypeError("must be positive")
return _value
def maybe_str(v: Any) -> Optional[str]:
if v is not None:
return str(v)
return None
def main():
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)
def main() -> Any:
if locale.nl_langinfo(locale.CODESET).upper() not in [
"US-ASCII",
"UTF-8",
"UTF8",
]:
sys.stderr.write(
"asciinema needs an ASCII or UTF-8 character encoding to run. "
"Check the output of `locale` command.\n"
)
return 1
try:
cfg = config.load()
except config.ConfigError as e:
sys.stderr.write(str(e) + '\n')
sys.exit(1)
sys.stderr.write(f"{e}\n")
return 1
# create the top-level parser
parser = argparse.ArgumentParser(
@@ -56,60 +72,152 @@ def main():
\x1b[1masciinema cat demo.cast\x1b[0m
For help on a specific command run:
\x1b[1masciinema <command> -h\x1b[0m""",
formatter_class=argparse.RawDescriptionHelpFormatter
\x1b[1masciinema <command> -h\x1b[0m""", # noqa: E501
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version", action="version", version=f"asciinema {__version__}"
)
parser.add_argument('--version', action='version', version='asciinema %s' % __version__)
subparsers = parser.add_subparsers()
# create the parser for the "rec" command
parser_rec = subparsers.add_parser('rec', help='Record terminal session')
parser_rec.add_argument('--stdin', help='enable stdin recording, disabled by default', action='store_true', default=cfg.record_stdin)
parser_rec.add_argument('--append', help='append to existing recording', action='store_true', default=False)
parser_rec.add_argument('--raw', help='save only raw stdout output', action='store_true', default=False)
parser_rec.add_argument('--overwrite', help='overwrite the file if it already exists', action='store_true', default=False)
parser_rec.add_argument('-c', '--command', help='command to record, defaults to $SHELL', default=cfg.record_command)
parser_rec.add_argument('-e', '--env', help='list of environment variables to capture, defaults to ' + config.DEFAULT_RECORD_ENV, default=cfg.record_env)
parser_rec.add_argument('-t', '--title', help='title of the asciicast')
parser_rec.add_argument('-i', '--idle-time-limit', help='limit recorded idle time to given number of seconds', type=positive_float, default=maybe_str(cfg.record_idle_time_limit))
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')
# create the parser for the `rec` command
parser_rec = subparsers.add_parser("rec", help="Record terminal session")
parser_rec.add_argument(
"--stdin",
help="enable stdin recording, disabled by default",
action="store_true",
default=cfg.record_stdin,
)
parser_rec.add_argument(
"--append",
help="append to existing recording",
action="store_true",
default=False,
)
parser_rec.add_argument(
"--raw",
help="save only raw stdout output",
action="store_true",
default=False,
)
parser_rec.add_argument(
"--overwrite",
help="overwrite the file if it already exists",
action="store_true",
default=False,
)
parser_rec.add_argument(
"-c",
"--command",
help="command to record, defaults to $SHELL",
default=cfg.record_command,
)
parser_rec.add_argument(
"-e",
"--env",
help="list of environment variables to capture, defaults to "
+ config.DEFAULT_RECORD_ENV,
default=cfg.record_env,
)
parser_rec.add_argument("-t", "--title", help="title of the asciicast")
parser_rec.add_argument(
"-i",
"--idle-time-limit",
help="limit recorded idle time to given number of seconds",
type=positive_float,
default=maybe_str(cfg.record_idle_time_limit),
)
parser_rec.add_argument(
"--cols",
help="override terminal columns for recorded process",
type=positive_int,
default=None,
)
parser_rec.add_argument(
"--rows",
help="override terminal rows for recorded process",
type=positive_int,
default=None,
)
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(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)')
# 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(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)')
# 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(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')
# 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(cmd=UploadCommand)
# create the parser for the "auth" command
parser_auth = subparsers.add_parser('auth', help='Manage recordings on asciinema.org account')
# create the parser for the `auth` command
parser_auth = subparsers.add_parser(
"auth", help="Manage recordings on asciinema.org account"
)
parser_auth.set_defaults(cmd=AuthCommand)
# parse the args and call whatever function was selected
args = parser.parse_args()
if hasattr(args, 'cmd'):
if hasattr(args, "cmd"):
command = args.cmd(args, cfg, os.environ)
code = command.execute()
sys.exit(code)
else:
parser.print_help()
sys.exit(1)
return code
parser.print_help()
return 1
if __name__ == '__main__':
main()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,11 +1,12 @@
import json
import platform
import re
import json
from typing import Any, Callable, Dict, Optional, Tuple, Union
from urllib.parse import urlparse
from asciinema import __version__
from asciinema.urllib_http_adapter import URLLibHttpAdapter
from asciinema.http_adapter import HTTPConnectionError
from . import __version__
from .http_adapter import HTTPConnectionError
from .urllib_http_adapter import URLLibHttpAdapter
class APIError(Exception):
@@ -13,73 +14,88 @@ class APIError(Exception):
class Api:
def __init__(self, url, user, install_id, http_adapter=None):
def __init__(
self,
url: str,
user: Optional[str],
install_id: str,
http_adapter: Any = None,
) -> None:
self.url = url
self.user = user
self.install_id = install_id
self.http_adapter = http_adapter if http_adapter is not None else URLLibHttpAdapter()
self.http_adapter = (
http_adapter if http_adapter is not None else URLLibHttpAdapter()
)
def hostname(self):
def hostname(self) -> Optional[str]:
return urlparse(self.url).hostname
def auth_url(self):
return "{}/connect/{}".format(self.url, self.install_id)
def auth_url(self) -> str:
return f"{self.url}/connect/{self.install_id}"
def upload_url(self):
return "{}/api/asciicasts".format(self.url)
def upload_url(self) -> str:
return f"{self.url}/api/asciicasts"
def upload_asciicast(self, path):
with open(path, 'rb') as f:
def upload_asciicast(self, path_: str) -> Tuple[Any, Any]:
with open(path_, "rb") as f:
try:
status, headers, body = self.http_adapter.post(
self.upload_url(),
files={"asciicast": ("ascii.cast", f)},
headers=self._headers(),
username=self.user,
password=self.install_id
password=self.install_id,
)
except HTTPConnectionError as e:
raise APIError(str(e))
raise APIError(str(e)) from e
if status != 200 and status != 201:
if status not in (200, 201):
self._handle_error(status, body)
if (headers.get('content-type') or '')[0:16] == 'application/json':
if (headers.get("content-type") or "")[0:16] == "application/json":
result = json.loads(body)
else:
result = {'url': body}
result = {"url": body}
return result, headers.get('Warning')
return result, headers.get("Warning")
def _headers(self):
return {'User-Agent': self._user_agent(), 'Accept': 'application/json'}
def _headers(self) -> Dict[str, Union[Callable[[], str], str]]:
return {"user-agent": self._user_agent(), "accept": "application/json"}
def _user_agent(self):
os = re.sub('([^-]+)-(.*)', '\\1/\\2', platform.platform())
@staticmethod
def _user_agent() -> str:
os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform())
return 'asciinema/%s %s/%s %s' % (__version__,
platform.python_implementation(),
platform.python_version(),
os
)
return (
f"asciinema/{__version__} {platform.python_implementation()}"
f"/{platform.python_version()} {os}"
)
def _handle_error(self, status, body):
@staticmethod
def _handle_error(status: int, body: bytes) -> None:
errors = {
400: "Invalid request: %s" % body,
400: f"Invalid request: {body.decode('utf-8', 'replace')}",
401: "Invalid or revoked install ID",
404: "API endpoint not found. This asciinema version may no longer be supported. Please upgrade to the latest version.",
404: (
"API endpoint not found. "
"This asciinema version may no longer be supported. "
"Please upgrade to the latest version."
),
413: "Sorry, your asciicast is too big.",
422: "Invalid asciicast: %s" % body,
503: "The server is down for maintenance. Try again in a minute."
422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
503: "The server is down for maintenance. Try again in a minute.",
}
error = errors.get(status)
if not error:
if status >= 500:
error = "The server is having temporary problems. Try again in a minute."
error = (
"The server is having temporary problems. "
"Try again in a minute."
)
else:
error = "HTTP status: %i" % status
error = f"HTTP status: {status}"
raise APIError(error)

View File

@@ -1,92 +1,116 @@
import sys
import os
from urllib.request import Request, urlopen
from urllib.parse import urlparse, urlunparse
import urllib.error
import html.parser
import gzip
import codecs
import gzip
import os
import sys
import urllib.error
from codecs import StreamReader
from html.parser import HTMLParser
from typing import Any, List, TextIO, Union
from urllib.parse import urlparse, urlunparse
from urllib.request import Request, urlopen
from . import v1
from . import v2
from . import v1, v2
class LoadError(Exception):
pass
class Parser(html.parser.HTMLParser):
def __init__(self):
html.parser.HTMLParser.__init__(self)
class Parser(HTMLParser):
def __init__(self) -> None:
HTMLParser.__init__(self)
self.url = None
def handle_starttag(self, tag, attrs_list):
# look for <link rel="alternate" type="application/x-asciicast" href="https://...cast">
if tag == 'link':
attrs = {}
for k, v in attrs_list:
attrs[k] = v
def error(self, message: str) -> None:
raise NotImplementedError(
"subclasses of ParserBase must override error()"
", but HTMLParser does not"
)
if attrs.get('rel') == 'alternate':
type = attrs.get('type')
if type == 'application/asciicast+json' or type == 'application/x-asciicast':
self.url = attrs.get('href')
def handle_starttag(self, tag: str, attrs: List[Any]) -> None:
# look for <link rel="alternate"
# type="application/x-asciicast"
# href="https://...cast">
if tag == "link":
# avoid modifying function signature keyword args from base class
_attrs = {}
for k, v in attrs:
_attrs[k] = v
if _attrs.get("rel") == "alternate":
type_ = _attrs.get("type")
if type_ in (
"application/asciicast+json",
"application/x-asciicast",
):
self.url = _attrs.get("href")
def open_url(url):
def open_url(url: str) -> Union[StreamReader, TextIO]:
if url == "-":
return sys.stdin
if url.startswith("ipfs://"):
url = "https://ipfs.io/ipfs/%s" % url[7:]
url = f"https://ipfs.io/ipfs/{url[7:]}"
elif url.startswith("dweb:/ipfs/"):
url = "https://ipfs.io/%s" % url[5:]
url = f"https://ipfs.io/{url[5:]}"
if url.startswith("http:") or url.startswith("https:"):
req = Request(url)
req.add_header('Accept-Encoding', 'gzip')
response = urlopen(req)
body = response
url = response.geturl() # final URL after redirects
req.add_header("Accept-Encoding", "gzip")
with urlopen(req) as response:
body = response
url = response.geturl() # final URL after redirects
if response.headers['Content-Encoding'] == 'gzip':
body = gzip.open(body)
if response.headers["Content-Encoding"] == "gzip":
body = gzip.open(body)
utf8_reader = codecs.getreader('utf-8')
content_type = response.headers['Content-Type']
utf8_reader = codecs.getreader("utf-8")
content_type = response.headers["Content-Type"]
if content_type and content_type.startswith('text/html'):
html = utf8_reader(body, errors='replace').read()
parser = Parser()
parser.feed(html)
new_url = parser.url
if content_type and content_type.startswith("text/html"):
html = utf8_reader(body, errors="replace").read()
parser = Parser()
parser.feed(html)
new_url = parser.url
if not new_url:
raise LoadError("""<link rel="alternate" type="application/x-asciicast" href="..."> not found in fetched HTML document""")
if not new_url:
raise LoadError(
'<link rel="alternate" '
'type="application/x-asciicast" '
'href="..."> '
"not found in fetched HTML document"
)
if "://" not in new_url:
base_url = urlparse(url)
if "://" not in new_url:
base_url = urlparse(url)
if new_url.startswith("/"):
new_url = urlunparse((base_url[0], base_url[1], new_url, '', '', ''))
else:
path = os.path.dirname(base_url[2]) + '/' + new_url
new_url = urlunparse((base_url[0], base_url[1], path, '', '', ''))
if new_url.startswith("/"):
new_url = urlunparse(
(base_url[0], base_url[1], new_url, "", "", "")
)
else:
path = f"{os.path.dirname(base_url[2])}/{new_url}"
new_url = urlunparse(
(base_url[0], base_url[1], path, "", "", "")
)
return open_url(new_url)
return open_url(new_url)
return utf8_reader(body, errors='strict')
return utf8_reader(body, errors="strict")
return open(url, mode='rt', encoding='utf-8')
return open(url, mode="rt", encoding="utf-8")
class open_from_url():
class open_from_url:
FORMAT_ERROR = "only asciicast v1 and v2 formats can be opened"
def __init__(self, url):
def __init__(self, url: str) -> None:
self.url = url
self.file: Union[StreamReader, TextIO, None] = None
self.context: Any = None
def __enter__(self):
def __enter__(self) -> Any:
try:
self.file = open_url(self.url)
first_line = self.file.readline()
@@ -98,11 +122,13 @@ class open_from_url():
try: # try v1 next
self.context = v1.open_from_file(first_line, self.file)
return self.context.__enter__()
except v1.LoadError:
raise LoadError(self.FORMAT_ERROR)
except v1.LoadError as e:
raise LoadError(self.FORMAT_ERROR) from e
except (OSError, urllib.error.HTTPError) as e:
raise LoadError(str(e))
raise LoadError(str(e)) from e
def __exit__(self, exc_type, exc_value, exc_traceback):
def __exit__(
self, exc_type: str, exc_value: str, exc_traceback: str
) -> None:
self.context.__exit__(exc_type, exc_value, exc_traceback)

View File

@@ -1,28 +1,41 @@
def to_relative_time(events):
from typing import Any, Generator, List, Optional
def to_relative_time(
events: Generator[List[Any], None, None]
) -> Generator[List[Any], None, None]:
prev_time = 0
for frame in events:
time, type, data = frame
time, type_, data = frame
delay = time - prev_time
prev_time = time
yield [delay, type, data]
yield [delay, type_, data]
def to_absolute_time(events):
def to_absolute_time(
events: Generator[List[Any], None, None]
) -> Generator[List[Any], None, None]:
time = 0
for frame in events:
delay, type, data = frame
delay, type_, data = frame
time = time + delay
yield [time, type, data]
yield [time, type_, data]
def cap_relative_time(events, time_limit):
def cap_relative_time(
events: Generator[List[Any], None, None], time_limit: Optional[float]
) -> Generator[List[Any], None, None]:
if time_limit:
return ([min(delay, time_limit), type, data] for delay, type, data in events)
else:
return events
return (
[min(delay, time_limit), type_, data]
for delay, type_, data in events
)
return events
def adjust_speed(events, speed):
return ([delay / speed, type, data] for delay, type, data in events)
def adjust_speed(
events: Generator[List[Any], None, None], speed: Any
) -> Generator[List[Any], None, None]:
return ([delay / speed, type_, data] for delay, type_, data in events)

View File

@@ -1,25 +1,48 @@
import os
import sys
from os import path
from typing import Any, Callable, Optional
from ..file_writer import file_writer
class writer():
class writer(file_writer):
def __init__( # pylint: disable=too-many-arguments
self,
path_: str,
metadata: Any = None,
append: bool = False,
buffering: int = 0,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
super().__init__(path_, on_error)
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
if (
append and path.exists(path_) and os.stat(path_).st_size == 0
): # true for pipes
append = False
self.path = path
self.buffering = buffering
self.mode = 'ab' if append else 'wb'
self.mode: str = "ab" if append else "wb"
self.metadata = metadata
def __enter__(self):
self.file = open(self.path, mode=self.mode, buffering=self.buffering)
return self
def write_stdout(self, _ts: float, data: Any) -> None:
self._write(data)
def __exit__(self, exc_type, exc_value, exc_traceback):
self.file.close()
def write_stdout(self, ts, data):
self.file.write(data)
def write_stdin(self, ts, data):
# pylint: disable=no-self-use
def write_stdin(self, ts: float, data: Any) -> None:
pass
# pylint: disable=consider-using-with
def _open_file(self) -> None:
if self.path == "-":
self.file = os.fdopen(
sys.stdout.fileno(),
mode=self.mode,
buffering=self.buffering,
closefd=False,
)
else:
self.file = open(
self.path, mode=self.mode, buffering=self.buffering
)

View File

@@ -1,13 +1,9 @@
import json
import json.decoder
from codecs import StreamReader
from json.decoder import JSONDecodeError
from typing import Any, Dict, Generator, List, Optional, TextIO, Union
from asciinema.asciicast.events import to_absolute_time
try:
JSONDecodeError = json.decoder.JSONDecodeError
except AttributeError:
JSONDecodeError = ValueError
from .events import to_absolute_time
class LoadError(Exception):
@@ -15,46 +11,52 @@ class LoadError(Exception):
class Asciicast:
def __init__(self, attrs):
self.version = 1
def __init__(self, attrs: Dict[str, Any]) -> None:
self.version: int = 1
self.__attrs = attrs
self.idle_time_limit = None # v1 doesn't store it
@property
def v2_header(self):
keys = ['width', 'height', 'duration', 'command', 'title', 'env']
header = {k: v for k, v in self.__attrs.items() if k in keys and v is not None}
def v2_header(self) -> Dict[str, Any]:
keys = ["width", "height", "duration", "command", "title", "env"]
header = {
k: v
for k, v in self.__attrs.items()
if k in keys and v is not None
}
return header
def __stdout_events(self):
for time, data in self.__attrs['stdout']:
yield [time, 'o', data]
def __stdout_events(self) -> Generator[List[Any], None, None]:
for time, data in self.__attrs["stdout"]:
yield [time, "o", data]
def events(self):
def events(self) -> Any:
return self.stdout_events()
def stdout_events(self):
def stdout_events(self) -> Generator[List[Any], None, None]:
return to_absolute_time(self.__stdout_events())
class open_from_file():
FORMAT_ERROR = "only asciicast v1 format can be opened"
class open_from_file:
FORMAT_ERROR: str = "only asciicast v1 format can be opened"
def __init__(self, first_line, file):
def __init__(
self, first_line: str, file: Union[TextIO, StreamReader]
) -> None:
self.first_line = first_line
self.file = file
def __enter__(self):
def __enter__(self) -> Optional[Asciicast]:
try:
attrs = json.loads(self.first_line + self.file.read())
if attrs.get('version') == 1:
if attrs.get("version") == 1:
return Asciicast(attrs)
else:
raise LoadError(self.FORMAT_ERROR)
except JSONDecodeError as e:
raise LoadError(self.FORMAT_ERROR)
except JSONDecodeError as e:
raise LoadError(self.FORMAT_ERROR) from e
def __exit__(self, exc_type, exc_value, exc_traceback):
def __exit__(
self, exc_type: str, exc_value: str, exc_traceback: str
) -> None:
self.file.close()

View File

@@ -1,12 +1,21 @@
import json
import json.decoder
import time
import codecs
import json
import os
import sys
from codecs import StreamReader
from json.decoder import JSONDecodeError
from typing import (
Any,
Callable,
Dict,
Generator,
List,
Optional,
TextIO,
Union,
)
try:
JSONDecodeError = json.decoder.JSONDecodeError
except AttributeError:
JSONDecodeError = ValueError
from ..file_writer import file_writer
class LoadError(Exception):
@@ -14,113 +23,149 @@ class LoadError(Exception):
class Asciicast:
def __init__(self, f, header):
self.version = 2
def __init__(
self, f: Union[TextIO, StreamReader], header: Dict[str, Any]
) -> None:
self.version: int = 2
self.__file = f
self.v2_header = header
self.idle_time_limit = header.get('idle_time_limit')
self.idle_time_limit = header.get("idle_time_limit")
def events(self):
def events(self) -> Generator[Any, None, None]:
for line in self.__file:
yield json.loads(line)
def stdout_events(self):
for time, type, data in self.events():
if type == 'o':
yield [time, type, data]
def stdout_events(self) -> Generator[List[Any], None, None]:
for time, type_, data in self.events():
if type_ == "o":
yield [time, type_, data]
def build_from_header_and_file(header, f):
def build_from_header_and_file(
header: Dict[str, Any], f: Union[StreamReader, TextIO]
) -> Asciicast:
return Asciicast(f, header)
class open_from_file():
class open_from_file:
FORMAT_ERROR = "only asciicast v2 format can be opened"
def __init__(self, first_line, file):
def __init__(
self, first_line: str, file: Union[StreamReader, TextIO]
) -> None:
self.first_line = first_line
self.file = file
def __enter__(self):
def __enter__(self) -> Asciicast:
try:
v2_header = json.loads(self.first_line)
if v2_header.get('version') == 2:
v2_header: Dict[str, Any] = json.loads(self.first_line)
if v2_header.get("version") == 2:
return build_from_header_and_file(v2_header, self.file)
else:
raise LoadError(self.FORMAT_ERROR)
except JSONDecodeError as e:
raise LoadError(self.FORMAT_ERROR)
except JSONDecodeError as e:
raise LoadError(self.FORMAT_ERROR) from e
def __exit__(self, exc_type, exc_value, exc_traceback):
def __exit__(
self, exc_type: str, exc_value: str, exc_traceback: str
) -> None:
self.file.close()
def get_duration(path):
with open(path, mode='rt', encoding='utf-8') as f:
def get_duration(path_: str) -> Any:
with open(path_, mode="rt", encoding="utf-8") as f:
first_line = f.readline()
with open_from_file(first_line, f) as a:
last_frame = None
for last_frame in a.stdout_events():
pass
return last_frame[0]
def build_header(width, height, metadata):
header = {'version': 2, 'width': width, 'height': height}
def build_header(
width: Optional[int], height: Optional[int], metadata: Any
) -> Dict[str, Any]:
header = {"version": 2, "width": width, "height": height}
header.update(metadata)
assert 'width' in header, 'width missing in metadata'
assert 'height' in header, 'height missing in metadata'
assert type(header['width']) == int
assert type(header['height']) == int
assert "width" in header, "width missing in metadata"
assert "height" in header, "height missing in metadata"
assert isinstance(header["width"], int)
assert isinstance(header["height"], int)
if 'timestamp' in header:
assert type(header['timestamp']) == int or type(header['timestamp']) == float
if "timestamp" in header:
assert isinstance(header["timestamp"], (int, float))
return header
class writer():
class writer(file_writer):
def __init__( # pylint: disable=too-many-arguments
self,
path_: str,
metadata: Any = None,
append: bool = False,
buffering: int = 1,
width: Optional[int] = None,
height: Optional[int] = None,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
super().__init__(path_, on_error)
def __init__(self, path, metadata=None, append=False, buffering=1, width=None, height=None):
self.path = path
self.buffering = buffering
self.stdin_decoder = codecs.getincrementaldecoder('UTF-8')('replace')
self.stdout_decoder = codecs.getincrementaldecoder('UTF-8')('replace')
self.stdin_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
self.stdout_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
if append:
self.mode = 'a'
self.mode = "a"
self.header = None
else:
self.mode = 'w'
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)
def __enter__(self) -> Any:
self._open_file()
if self.header:
self.__write_line(self.header)
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self.file.close()
def write_stdout(self, ts, data):
if type(data) == str:
data = data.encode(encoding='utf-8', errors='strict')
def write_stdout(self, ts: float, data: Union[str, bytes]) -> None:
if isinstance(data, str):
data = data.encode(encoding="utf-8", errors="strict")
data = self.stdout_decoder.decode(data)
self.__write_event(ts, 'o', data)
self.__write_event(ts, "o", data)
def write_stdin(self, ts, data):
if type(data) == str:
data = data.encode(encoding='utf-8', errors='strict')
def write_stdin(self, ts: float, data: Union[str, bytes]) -> None:
if isinstance(data, str):
data = data.encode(encoding="utf-8", errors="strict")
data = self.stdin_decoder.decode(data)
self.__write_event(ts, 'i', data)
self.__write_event(ts, "i", data)
def __write_event(self, ts, etype, data):
# pylint: disable=consider-using-with
def _open_file(self) -> None:
if self.path == "-":
self.file = os.fdopen(
sys.stdout.fileno(),
mode=self.mode,
buffering=self.buffering,
encoding="utf-8",
closefd=False,
)
else:
self.file = open(
self.path,
mode=self.mode,
buffering=self.buffering,
encoding="utf-8",
)
def __write_event(self, ts: float, etype: str, data: str) -> None:
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_line(self, obj: Any) -> None:
line = json.dumps(
obj, ensure_ascii=False, indent=None, separators=(", ", ": ")
)
self._write(f"{line}\n")

View File

@@ -1,31 +1,46 @@
from typing import Any, Optional
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
from multiprocessing import Process, Queue, synchronize
# pylint: disable=pointless-statement
lambda _=synchronize: None # avoid pruning import
except ImportError:
from threading import Thread as Process
from queue import Queue
from queue import Queue # type: ignore
from threading import Thread as Process # type: ignore
class async_worker():
class async_worker:
def __init__(self) -> None:
self.queue: Queue[Any] = Queue()
self.process: Optional[Process] = None
def __init__(self):
self.queue = Queue()
def __enter__(self):
def __enter__(self) -> Any:
self.process = Process(target=self.run)
self.process.start()
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
def __exit__(
self, exc_type: str, exc_value: str, exc_traceback: str
) -> None:
self.queue.put(None)
assert isinstance(self.process, Process)
self.process.join()
def enqueue(self, payload):
if self.process.exitcode != 0:
raise RuntimeError(
f"worker process exited with code {self.process.exitcode}"
)
def enqueue(self, payload: Any) -> None:
self.queue.put(payload)
def run(self):
def run(self) -> None:
payload: Any
for payload in iter(self.queue.get, None):
self.perform(payload)
# pylint: disable=no-member
self.perform(payload) # type: ignore[attr-defined]

View File

@@ -1,16 +1,20 @@
from asciinema.commands.command import Command
from typing import Any, Dict
from ..config import Config
from .command import Command
class AuthCommand(Command):
def __init__(self, args, config, env):
def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
Command.__init__(self, args, config, env)
def execute(self):
self.print('Open the following URL in a web browser to link your '
'install ID with your %s user account:\n\n'
'%s\n\n'
'This will associate all recordings uploaded from this machine '
'(past and future ones) to your account, '
'and allow you to manage them (change title/theme, delete) at %s.'
% (self.api.hostname(), self.api.auth_url(), self.api.hostname()))
def execute(self) -> None:
self.print(
f"Open the following URL in a web browser to link your install ID "
f"with your {self.api.hostname()} user account:\n\n"
f"{self.api.auth_url()}\n\n"
"This will associate all recordings uploaded from this machine "
"(past and future ones) to your account"
", and allow you to manage them (change title/theme, delete) at "
f"{self.api.hostname()}."
)

View File

@@ -1,27 +1,28 @@
import sys
from typing import Any, Dict
from asciinema.commands.command import Command
from asciinema.term import raw
import asciinema.asciicast as asciicast
from .. import asciicast
from ..config import Config
from ..tty_ import raw
from .command import Command
class CatCommand(Command):
def __init__(self, args, config, env):
def __init__(self, args: Any, config: Config, env: Dict[str, str]):
Command.__init__(self, args, config, env)
self.filename = args.filename
def execute(self):
def execute(self) -> int:
try:
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()
with open("/dev/tty", "rt", encoding="utf-8") as stdin:
with raw(stdin.fileno()):
with asciicast.open_from_url(self.filename) as a:
for _, _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))
self.print_error(f"printing failed: {str(e)}")
return 1
return 0

View File

@@ -1,23 +1,34 @@
import os
import sys
from typing import Any, Dict, Optional
from asciinema.api import Api
from ..api import Api
from ..config import Config
class Command:
def __init__(self, args, config, env):
self.quiet = False
def __init__(self, _args: Any, config: Config, env: Dict[str, str]):
self.quiet: bool = False
self.api = Api(config.api_url, env.get("USER"), config.install_id)
def print(self, text, file=sys.stdout, end="\n", force=False):
def print(
self,
text: str,
end: str = "\n",
color: Optional[int] = None,
force: bool = False,
) -> None:
if not self.quiet or force:
print(text, file=file, end=end)
if color is not None and os.isatty(sys.stderr.fileno()):
text = f"\x1b[0;3{color}m{text}\x1b[0m"
def print_info(self, text):
self.print("\x1b[0;32masciinema: %s\x1b[0m" % text)
print(text, file=sys.stderr, end=end)
def print_warning(self, text):
self.print("\x1b[0;33masciinema: %s\x1b[0m" % text)
def print_info(self, text: str) -> None:
self.print(f"asciinema: {text}", color=2)
def print_error(self, text):
self.print("\x1b[0;31masciinema: %s\x1b[0m" % text, file=sys.stderr, force=True)
def print_warning(self, text: str) -> None:
self.print(f"asciinema: {text}", color=3)
def print_error(self, text: str) -> None:
self.print(f"asciinema: {text}", color=1, force=True)

View File

@@ -1,28 +1,38 @@
from asciinema.commands.command import Command
from asciinema.player import Player
import asciinema.asciicast as asciicast
from typing import Any, Dict, Optional
from .. import asciicast
from ..commands.command import Command
from ..config import Config
from ..player import Player
class PlayCommand(Command):
def __init__(self, args, config, env, player=None):
def __init__(
self,
args: Any,
config: Config,
env: Dict[str, str],
player: Optional[Player] = None,
) -> 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
"pause": config.play_pause_key,
"step": config.play_step_key,
}
def execute(self):
def execute(self) -> int:
try:
with asciicast.open_from_url(self.filename) as a:
self.player.play(a, self.idle_time_limit, self.speed, self.key_bindings)
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))
self.print_error(f"playback failed: {str(e)}")
return 1
except KeyboardInterrupt:
return 1

View File

@@ -1,53 +1,63 @@
import os
import sys
import tempfile
from tempfile import NamedTemporaryFile
from typing import Any, Dict, Optional
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
from .. import notifier, recorder
from ..api import APIError
from ..asciicast import raw, v2
from ..commands.command import Command
from ..config import Config
class RecordCommand(Command):
def __init__(self, args, config, env):
class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
Command.__init__(self, args, config, env)
self.quiet = args.quiet
self.filename = args.filename
self.rec_stdin = args.stdin
self.record_stdin = args.stdin
self.command = args.command
self.env_whitelist = args.env
self.title = args.title
self.assume_yes = args.yes or args.quiet
self.idle_time_limit = args.idle_time_limit
self.cols_override = args.cols
self.rows_override = args.rows
self.append = args.append
self.overwrite = args.overwrite
self.raw = args.raw
self.writer = raw.writer if args.raw else v2.writer
self.notifier = notifier.get_notifier(config.notifications_enabled, config.notifications_command)
self.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
"prefix": config.record_prefix_key,
"pause": config.record_pause_key,
}
def execute(self):
# pylint: disable=too-many-branches
# pylint: disable=too-many-return-statements
# pylint: disable=too-many-statements
def execute(self) -> int:
upload = False
append = self.append
if self.filename == "":
if self.raw:
self.print_error("filename required when recording in raw mode")
self.print_error(
"filename required when recording in raw mode"
)
return 1
else:
self.filename = _tmp_path()
upload = True
self.filename = _tmp_path()
upload = True
if os.path.exists(self.filename):
if self.filename == "-":
append = False
elif os.path.exists(self.filename):
if not os.access(self.filename, os.W_OK):
self.print_error("can't write to %s" % self.filename)
self.print_error(f"can't write to {self.filename}")
return 1
if os.stat(self.filename).st_size > 0 and self.overwrite:
@@ -55,22 +65,42 @@ class RecordCommand(Command):
append = False
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")
self.print_error(f"{self.filename} already exists, aborting")
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
elif append:
self.print_warning(
f"{self.filename} does not exist, not appending"
)
append = False
if append:
self.print_info("appending to asciicast at %s" % self.filename)
self.print_info(f"appending to asciicast at {self.filename}")
else:
self.print_info("recording asciicast to %s" % self.filename)
self.print_info(f"recording asciicast to {self.filename}")
if self.command:
self.print_info("""exit opened program when you're done""")
else:
self.print_info("""press <ctrl-d> or type "exit" when you're done""")
self.print_info(
"""press <ctrl-d> or type "exit" when you're done"""
)
vars = filter(None, map((lambda var: var.strip()), self.env_whitelist.split(',')))
vars_: Any = filter(
None,
map(
(lambda var: var.strip()), # type: ignore
self.env_whitelist.split(","),
),
)
try:
recorder.record(
@@ -80,27 +110,33 @@ class RecordCommand(Command):
title=self.title,
idle_time_limit=self.idle_time_limit,
command_env=self.env,
capture_env=vars,
rec_stdin=self.rec_stdin,
capture_env=vars_,
record_stdin=self.record_stdin,
writer=self.writer,
notifier=self.notifier,
key_bindings=self.key_bindings
notify=self.notifier.notify,
key_bindings=self.key_bindings,
cols_override=self.cols_override,
rows_override=self.rows_override,
)
except v2.LoadError:
self.print_error("can only append to asciicast v2 format recordings")
self.print_error(
"can only append to asciicast v2 format recordings"
)
return 1
self.print_info("recording finished")
if upload:
if not self.assume_yes:
self.print_info("press <enter> to upload to %s, <ctrl-c> to save locally"
% self.api.hostname())
self.print_info(
f"press <enter> to upload to {self.api.hostname()}"
", <ctrl-c> to save locally"
)
try:
sys.stdin.readline()
except KeyboardInterrupt:
self.print("\r", end="")
self.print_info("asciicast saved to %s" % self.filename)
self.print_info(f"asciicast saved to {self.filename}")
return 0
try:
@@ -110,20 +146,20 @@ class RecordCommand(Command):
self.print_warning(warn)
os.remove(self.filename)
self.print(result.get('message') or result['url'])
self.print(result.get("message") or result["url"])
except APIError as e:
self.print("\r\x1b[A", end="")
self.print_error("upload failed: %s" % str(e))
self.print_error("retry later by running: asciinema upload %s" % self.filename)
self.print_error(f"upload failed: {str(e)}")
self.print_error(
f"retry later by running: asciinema upload {self.filename}"
)
return 1
else:
self.print_info("asciicast saved to %s" % self.filename)
self.print_info(f"asciicast saved to {self.filename}")
return 0
def _tmp_path():
fd, path = tempfile.mkstemp(suffix='-ascii.cast')
os.close(fd)
return path
def _tmp_path() -> Optional[str]:
return NamedTemporaryFile(suffix="-ascii.cast", delete=False).name

View File

@@ -1,29 +1,33 @@
from asciinema.commands.command import Command
from asciinema.api import APIError
from typing import Any
from ..api import APIError
from ..config import Config
from .command import Command
class UploadCommand(Command):
def __init__(self, args, config, env):
def __init__(self, args: Any, config: Config, env: Any) -> None:
Command.__init__(self, args, config, env)
self.filename = args.filename
def execute(self):
def execute(self) -> int:
try:
result, warn = self.api.upload_asciicast(self.filename)
if warn:
self.print_warning(warn)
self.print(result.get('message') or result['url'])
self.print(result.get("message") or result["url"])
except OSError as e:
self.print_error("upload failed: %s" % str(e))
self.print_error(f"upload failed: {str(e)}")
return 1
except APIError as e:
self.print_error("upload failed: %s" % str(e))
self.print_error("retry later by running: asciinema upload %s" % self.filename)
self.print_error(f"upload failed: {str(e)}")
self.print_error(
f"retry later by running: asciinema upload {self.filename}"
)
return 1
return 0

View File

@@ -1,168 +1,198 @@
import os
import os.path as path
import sys
import uuid
import configparser
import os
from os import path
from typing import Any, Dict, Optional
from uuid import uuid4
DEFAULT_API_URL: str = "https://asciinema.org"
DEFAULT_RECORD_ENV: str = "SHELL,TERM"
class ConfigError(Exception):
pass
DEFAULT_API_URL = 'https://asciinema.org'
DEFAULT_RECORD_ENV = 'SHELL,TERM'
class Config:
def __init__(self, config_home, env=None):
def __init__(
self,
config_home: Any,
env: Optional[Dict[str, str]] = None,
) -> None:
self.config_home = config_home
self.config_file_path = path.join(config_home, "config")
self.install_id_path = path.join(self.config_home, 'install-id')
self.install_id_path = path.join(self.config_home, "install-id")
self.config = configparser.ConfigParser()
self.config.read(self.config_file_path)
self.env = env if env is not None else os.environ
def upgrade(self):
def upgrade(self) -> None:
try:
self.install_id
except ConfigError:
id = self.__api_token() or self.__user_token() or self.__gen_install_id()
self.__save_install_id(id)
id_ = (
self.__api_token()
or self.__user_token()
or self.__gen_install_id()
)
self.__save_install_id(id_)
items = {name: dict(section) for (name, section) in self.config.items()}
if items == {'DEFAULT': {}, 'api': {'token': id}} or items == {'DEFAULT': {}, 'user': {'token': id}}:
items = {
name: dict(section) for (name, section) in self.config.items()
}
if items in (
{"DEFAULT": {}, "api": {"token": id_}},
{"DEFAULT": {}, "user": {"token": id_}},
):
os.remove(self.config_file_path)
if self.env.get('ASCIINEMA_API_TOKEN'):
raise ConfigError('ASCIINEMA_API_TOKEN variable is no longer supported, please use ASCIINEMA_INSTALL_ID instead')
if self.env.get("ASCIINEMA_API_TOKEN"):
raise ConfigError(
"ASCIINEMA_API_TOKEN variable is no longer supported"
", please use ASCIINEMA_INSTALL_ID instead"
)
def __read_install_id(self):
def __read_install_id(self) -> Optional[str]:
p = self.install_id_path
if path.isfile(p):
with open(p, 'r') as f:
with open(p, "r", encoding="utf-8") as f:
return f.read().strip()
return None
def __gen_install_id(self):
return str(uuid.uuid4())
@staticmethod
def __gen_install_id() -> str:
return f"{uuid4()}"
def __save_install_id(self, id):
def __save_install_id(self, id_: str) -> None:
self.__create_config_home()
with open(self.install_id_path, 'w') as f:
f.write(id)
with open(self.install_id_path, "w", encoding="utf-8") as f:
f.write(id_)
def __create_config_home(self):
def __create_config_home(self) -> None:
if not path.exists(self.config_home):
os.makedirs(self.config_home)
def __api_token(self):
def __api_token(self) -> Optional[str]:
try:
return self.config.get('api', 'token')
return self.config.get("api", "token")
except (configparser.NoOptionError, configparser.NoSectionError):
pass
return None
def __user_token(self):
def __user_token(self) -> Optional[str]:
try:
return self.config.get('user', 'token')
return self.config.get("user", "token")
except (configparser.NoOptionError, configparser.NoSectionError):
pass
return None
@property
def install_id(self):
id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id()
def install_id(self) -> str:
id_ = self.env.get("ASCIINEMA_INSTALL_ID") or self.__read_install_id()
if id:
return id
else:
raise ConfigError('no install ID found')
if id_:
return id_
raise ConfigError("no install ID found")
@property
def api_url(self):
def api_url(self) -> str:
return self.env.get(
'ASCIINEMA_API_URL',
self.config.get('api', 'url', fallback=DEFAULT_API_URL)
"ASCIINEMA_API_URL",
self.config.get("api", "url", fallback=DEFAULT_API_URL),
)
@property
def record_stdin(self):
return self.config.getboolean('record', 'stdin', fallback=False)
def record_stdin(self) -> bool:
return self.config.getboolean("record", "stdin", fallback=False)
@property
def record_command(self):
return self.config.get('record', 'command', fallback=None)
def record_command(self) -> Optional[str]:
return self.config.get("record", "command", fallback=None)
@property
def record_env(self):
return self.config.get('record', 'env', fallback=DEFAULT_RECORD_ENV)
def record_env(self) -> str:
return self.config.get("record", "env", fallback=DEFAULT_RECORD_ENV)
@property
def record_idle_time_limit(self):
fallback = self.config.getfloat('record', 'maxwait', fallback=None) # pre 2.0
return self.config.getfloat('record', 'idle_time_limit', fallback=fallback)
def record_idle_time_limit(self) -> Optional[float]:
fallback = self.config.getfloat(
"record", "maxwait", fallback=None
) # pre 2.0
return self.config.getfloat(
"record", "idle_time_limit", fallback=fallback
)
@property
def record_yes(self):
return self.config.getboolean('record', 'yes', fallback=False)
def record_yes(self) -> bool:
return self.config.getboolean("record", "yes", fallback=False)
@property
def record_quiet(self):
return self.config.getboolean('record', 'quiet', fallback=False)
def record_quiet(self) -> bool:
return self.config.getboolean("record", "quiet", fallback=False)
@property
def record_prefix_key(self):
return self.__get_key('record', 'prefix')
def record_prefix_key(self) -> Any:
return self.__get_key("record", "prefix")
@property
def record_pause_key(self):
return self.__get_key('record', 'pause', 'C-\\')
def record_pause_key(self) -> Any:
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
return self.config.getfloat('play', 'idle_time_limit', fallback=fallback)
def play_idle_time_limit(self) -> Optional[float]:
fallback = self.config.getfloat(
"play", "maxwait", fallback=None
) # pre 2.0
return self.config.getfloat(
"play", "idle_time_limit", fallback=fallback
)
@property
def play_speed(self):
return self.config.getfloat('play', 'speed', fallback=1.0)
def play_speed(self) -> float:
return self.config.getfloat("play", "speed", fallback=1.0)
@property
def play_pause_key(self):
return self.__get_key('play', 'pause', ' ')
def play_pause_key(self) -> Any:
return self.__get_key("play", "pause", " ")
@property
def play_step_key(self):
return self.__get_key('play', 'step', '.')
def play_step_key(self) -> Any:
return self.__get_key("play", "step", ".")
@property
def notifications_enabled(self):
return self.config.getboolean('notifications', 'enabled', fallback=True)
def notifications_enabled(self) -> bool:
return self.config.getboolean(
"notifications", "enabled", fallback=True
)
@property
def notifications_command(self):
return self.config.get('notifications', 'command', fallback=None)
def notifications_command(self) -> Optional[str]:
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)
def __get_key(self, section: str, name: str, default: Any = None) -> Any:
key = self.config.get(section, f"{name}_key", fallback=default)
if key:
if len(key) == 3:
upper_key = key.upper()
if upper_key[0] == 'C' and upper_key[1] == '-':
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')
raise ConfigError(
f"invalid {name} key definition '{key}' - use"
f": {name}_key = C-x (with control key modifier)"
f", or {name}_key = x (with no modifier)"
)
return key.encode("utf-8")
return None
def get_config_home(env=os.environ):
def get_config_home(env: Any = None) -> Any:
if env is None:
env = os.environ
env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
env_xdg_config_home = env.get("XDG_CONFIG_HOME")
env_home = env.get("HOME")
config_home = None
config_home: Optional[str] = None
if env_asciinema_config_home:
config_home = env_asciinema_config_home
@@ -175,12 +205,16 @@ def get_config_home(env=os.environ):
else:
config_home = path.join(env_home, ".config", "asciinema")
else:
raise Exception("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME")
raise Exception(
"need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME"
)
return config_home
def load(env=os.environ):
def load(env: Any = None) -> Config:
if env is None:
env = os.environ
config = Config(get_config_home(env), env)
config.upgrade()
return config

44
asciinema/file_writer.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import stat
from typing import IO, Any, Callable, Optional
class file_writer:
def __init__(
self,
path: str,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
self.path = path
self.file: Optional[IO[Any]] = None
self.on_error = on_error or noop
def __enter__(self) -> Any:
self._open_file()
return self
def __exit__(
self, exc_type: str, exc_value: str, exc_traceback: str
) -> None:
assert self.file is not None
self.file.close()
def _open_file(self) -> None:
raise NotImplementedError
def _write(self, data: Any) -> None:
try:
self.file.write(data) # type: ignore
except BrokenPipeError as e:
if self.path != "-" and stat.S_ISFIFO(os.stat(self.path).st_mode):
self.on_error("Broken pipe, reopening...")
self._open_file()
self.on_error("Output pipe reopened successfully")
self.file.write(data) # type: ignore
else:
self.on_error("Output pipe broken")
raise e
def noop(_: Any) -> None:
return None

View File

@@ -1,83 +1,121 @@
import os.path
import shutil
import subprocess
from os import environ, path
from typing import Dict, List, Optional, Union
class Notifier():
def is_available(self):
class Notifier:
def __init__(self, cmd: str) -> None:
self.cmd = cmd
@staticmethod
def get_icon_path() -> Optional[str]:
path_ = path.join(
path.dirname(path.realpath(__file__)),
"data/icon-256x256.png",
)
if path.exists(path_):
return path_
return None
def args(self, _text: str) -> List[str]:
return ["/bin/sh", "-c", self.cmd]
def is_available(self) -> bool:
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
def notify(self, text: str) -> None:
# We do not want to raise a `CalledProcessError` on command failure.
# pylint: disable=subprocess-run-check
# We do not 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
subprocess.run(self.args(text), capture_output=True)
class AppleScriptNotifier(Notifier):
cmd = "osascript"
def __init__(self) -> None:
super().__init__("osascript")
def args(self, text):
def args(self, text: str) -> List[str]:
text = text.replace('"', '\\"')
return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)]
return [
self.cmd,
"-e",
f'display notification "{text}" with title "asciinema"',
]
class LibNotifyNotifier(Notifier):
cmd = "notify-send"
def __init__(self) -> None:
super().__init__("notify-send")
def args(self, text):
def args(self, text: str) -> List[str]:
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]
return [self.cmd, "-i", icon_path, "asciinema", text]
return [self.cmd, "asciinema", text]
class TerminalNotifier(Notifier):
cmd = "terminal-notifier"
def __init__(self) -> None:
super().__init__("terminal-notifier")
def args(self, text):
def args(self, text: str) -> List[str]:
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]
return [
"terminal-notifier",
"-title",
"asciinema",
"-message",
text,
"-appIcon",
icon_path,
]
return [
"terminal-notifier",
"-title",
"asciinema",
"-message",
text,
]
class CustomCommandNotifier(Notifier):
def __init__(self, command):
Notifier.__init__(self)
self.command = command
def env(self, text: str) -> Dict[str, str]:
icon_path = self.get_icon_path()
env = environ.copy()
env["TEXT"] = text
if icon_path is not None:
env["ICON_PATH"] = icon_path
return env
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)
def notify(self, text: str) -> None:
# We do not want to raise a `CalledProcessError` on command failure.
# pylint: disable=subprocess-run-check
subprocess.run(
self.args(text), env=self.env(text), capture_output=True
)
class NoopNotifier():
def notify(self, text):
class NoopNotifier: # pylint: disable=too-few-public-methods
def notify(self, text: str) -> None:
pass
def get_notifier(enabled=True, command=None):
def get_notifier(
enabled: bool = True, command: Optional[str] = None
) -> Union[Notifier, NoopNotifier]:
if enabled:
if command:
return CustomCommandNotifier(command)
else:
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
n = c()
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
n = c()
if n.is_available():
return n
if n.is_available():
return n
return NoopNotifier()

View File

@@ -1,25 +1,43 @@
import os
import sys
import time
from typing import Any, Dict, Optional, TextIO, Union
import asciinema.asciicast.events as ev
from asciinema.term import raw, read_blocking
from .asciicast import events as ev
from .asciicast.v1 import Asciicast as v1
from .asciicast.v2 import Asciicast as v2
from .tty_ import raw, read_blocking
class Player:
def play(self, asciicast, idle_time_limit=None, speed=1.0, key_bindings={}):
class Player: # pylint: disable=too-few-public-methods
def play(
self,
asciicast: Union[v1, v2],
idle_time_limit: Optional[int] = None,
speed: float = 1.0,
key_bindings: Optional[Dict[str, Any]] = None,
) -> None:
if key_bindings is None:
key_bindings = {}
try:
stdin = open('/dev/tty')
with raw(stdin.fileno()):
self._play(asciicast, idle_time_limit, speed, stdin, key_bindings)
except Exception:
with open("/dev/tty", "rt", encoding="utf-8") as stdin:
with raw(stdin.fileno()):
self._play(
asciicast, idle_time_limit, speed, stdin, key_bindings
)
except Exception: # pylint: disable=broad-except
self._play(asciicast, idle_time_limit, speed, None, key_bindings)
def _play(self, asciicast, idle_time_limit, speed, stdin, key_bindings):
@staticmethod
def _play( # pylint: disable=too-many-locals
asciicast: Union[v1, v2],
idle_time_limit: Optional[int],
speed: float,
stdin: Optional[TextIO],
key_bindings: Dict[str, Any],
) -> None:
idle_time_limit = idle_time_limit or asciicast.idle_time_limit
pause_key = key_bindings.get('pause')
step_key = key_bindings.get('step')
pause_key = key_bindings.get("pause")
step_key = key_bindings.get("step")
stdout = asciicast.stdout_events()
stdout = ev.to_relative_time(stdout)
@@ -30,7 +48,7 @@ class Player:
base_time = time.time()
ctrl_c = False
paused = False
pause_time = None
pause_time: Optional[float] = None
for t, _type, text in stdout:
delay = t - (time.time() - base_time)
@@ -46,7 +64,8 @@ class Player:
if data == pause_key:
paused = False
base_time = base_time + (time.time() - pause_time)
assert pause_time is not None
base_time += time.time() - pause_time
break
if data == step_key:

View File

@@ -1,176 +0,0 @@
import array
import errno
import fcntl
import io
import os
import pty
import select
import shlex
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, 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():
'''
Sets the window size of the child pty based on the window size
of our own controlling terminal.
'''
# Get the terminal size of the real terminal, set it on the pseudoterminal.
if os.isatty(pty.STDOUT_FILENO):
buf = array.array('h', [0, 0, 0, 0])
fcntl.ioctl(pty.STDOUT_FILENO, termios.TIOCGWINSZ, buf, True)
else:
buf = array.array('h', [24, 80, 0, 0])
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, buf)
def _write_stdout(data):
'''Writes to stdout as if the child process had written the data.'''
os.write(pty.STDOUT_FILENO, data)
def _handle_master_read(data):
'''Handles new data on child process stdout.'''
if not pause_time:
writer.write_stdout(time.time() - start_time, data)
_write_stdout(data)
def _write_master(data):
'''Writes to the child process from its controlling terminal.'''
while data:
n = os.write(master_fd, data)
data = data[n:]
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 and not pause_time:
writer.write_stdin(time.time() - start_time, data)
def _signals(signal_list):
old_handlers = []
for sig, handler in signal_list:
old_handlers.append((sig, signal.signal(sig, handler)))
return old_handlers
def _copy(signal_fd):
'''Main select loop.
Passes control to _master_read() or _stdin_read()
when new data arrives.
'''
fds = [master_fd, pty.STDIN_FILENO, signal_fd]
while True:
try:
rfds, wfds, xfds = select.select(fds, [], [])
except OSError as e: # Python >= 3.3
if e.errno == errno.EINTR:
continue
except select.error as e: # Python < 3.3
if e.args[0] == 4:
continue
if master_fd in rfds:
data = os.read(master_fd, 1024)
if not data: # Reached EOF.
fds.remove(master_fd)
else:
_handle_master_read(data)
if pty.STDIN_FILENO in rfds:
data = os.read(pty.STDIN_FILENO, 1024)
if not data:
fds.remove(pty.STDIN_FILENO)
else:
_handle_stdin_read(data)
if signal_fd in rfds:
data = os.read(signal_fd, 1024)
if data:
signals = struct.unpack('%uB' % len(data), data)
for sig in signals:
if sig in [signal.SIGCHLD, signal.SIGHUP, signal.SIGTERM, signal.SIGQUIT]:
os.close(master_fd)
return
elif sig == signal.SIGWINCH:
_set_pty_size()
pid, master_fd = pty.fork()
if pid == pty.CHILD:
os.execvpe(command[0], command, env)
pipe_r, pipe_w = os.pipe()
flags = fcntl.fcntl(pipe_w, fcntl.F_GETFL, 0)
flags = flags | os.O_NONBLOCK
flags = fcntl.fcntl(pipe_w, fcntl.F_SETFL, flags)
signal.set_wakeup_fd(pipe_w)
old_handlers = _signals(map(lambda s: (s, lambda signal, frame: None),
[signal.SIGWINCH,
signal.SIGCHLD,
signal.SIGHUP,
signal.SIGTERM,
signal.SIGQUIT]))
_set_pty_size()
start_time = time.time() - time_offset
with raw(pty.STDIN_FILENO):
try:
_copy(pipe_r)
except (IOError, OSError):
pass
_signals(old_handlers)
os.waitpid(pid, 0)

183
asciinema/pty_.py Normal file
View File

@@ -0,0 +1,183 @@
import array
import errno
import fcntl
import os
import pty
import select
import signal
import struct
import termios
import time
from typing import Any, Callable, Dict, List, Optional, Tuple
from .tty_ import raw
EXIT_SIGNALS = [
signal.SIGCHLD,
signal.SIGHUP,
signal.SIGTERM,
signal.SIGQUIT,
]
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements
def record(
command: Any,
env: Dict[str, str],
writer: Any,
get_tty_size: Callable[[], Tuple[int, int]],
notify: Callable[[str], None],
key_bindings: Dict[str, Any],
tty_stdin_fd: int = pty.STDIN_FILENO,
tty_stdout_fd: int = pty.STDOUT_FILENO,
) -> None:
pty_fd: Any = None
start_time: Optional[float] = None
pause_time: Optional[float] = None
prefix_mode: bool = False
prefix_key = key_bindings.get("prefix")
pause_key = key_bindings.get("pause")
def set_pty_size() -> None:
cols, rows = get_tty_size()
buf = array.array("h", [rows, cols, 0, 0])
fcntl.ioctl(pty_fd, termios.TIOCSWINSZ, buf)
def handle_master_read(data: Any) -> None:
os.write(tty_stdout_fd, data)
if not pause_time:
assert start_time is not None
writer.write_stdout(time.time() - start_time, data)
def handle_stdin_read(data: Any) -> None:
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:
assert start_time is not None
start_time += time.time() - pause_time
pause_time = None
notify("Resumed recording")
else:
pause_time = time.time()
notify("Paused recording")
return
remaining_data = data
while remaining_data:
n = os.write(pty_fd, remaining_data)
remaining_data = remaining_data[n:]
if not pause_time:
assert start_time is not None
writer.write_stdin(time.time() - start_time, data)
def copy(signal_fd: int) -> None: # pylint: disable=too-many-branches
fds = [pty_fd, tty_stdin_fd, signal_fd]
stdin_fd = pty.STDIN_FILENO
if not os.isatty(stdin_fd):
fds.append(stdin_fd)
while True:
try:
rfds, _, _ = select.select(fds, [], [])
except OSError as e: # Python >= 3.3
if e.errno == errno.EINTR:
continue
if pty_fd in rfds:
data = os.read(pty_fd, 1024)
if not data: # Reached EOF.
fds.remove(pty_fd)
else:
handle_master_read(data)
if tty_stdin_fd in rfds:
data = os.read(tty_stdin_fd, 1024)
if not data:
fds.remove(tty_stdin_fd)
else:
handle_stdin_read(data)
if stdin_fd in rfds:
data = os.read(stdin_fd, 1024)
if not data:
fds.remove(stdin_fd)
else:
handle_stdin_read(data)
if signal_fd in rfds:
data = os.read(signal_fd, 1024)
if data:
signals = struct.unpack(f"{len(data)}B", data)
for sig in signals:
if sig in EXIT_SIGNALS:
os.close(pty_fd)
return None
if sig == signal.SIGWINCH:
set_pty_size()
pid, pty_fd = pty.fork()
if pid == pty.CHILD:
os.execvpe(command[0], command, env)
start_time = time.time()
set_pty_size()
with SignalFD(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd:
with raw(tty_stdin_fd):
try:
copy(sig_fd)
except (IOError, OSError):
pass
os.waitpid(pid, 0)
class SignalFD:
def __init__(self, signals: List[signal.Signals]) -> None:
self.signals = signals
self.orig_handlers: List[Tuple[signal.Signals, Any]] = []
self.orig_wakeup_fd: Optional[int] = None
def __enter__(self) -> int:
r, w = os.pipe()
flags = fcntl.fcntl(w, fcntl.F_GETFL, 0) | os.O_NONBLOCK
fcntl.fcntl(w, fcntl.F_SETFL, flags)
self.orig_wakeup_fd = signal.set_wakeup_fd(w)
for sig, handler in self._noop_handlers(self.signals):
self.orig_handlers.append((sig, signal.signal(sig, handler)))
return r
def __exit__(self, type_: str, value: str, traceback: str) -> None:
assert self.orig_wakeup_fd is not None
signal.set_wakeup_fd(self.orig_wakeup_fd)
for sig, handler in self.orig_handlers:
signal.signal(sig, handler)
@staticmethod
def _noop_handlers(
signals: List[signal.Signals],
) -> List[Tuple[signal.Signals, Any]]:
return list(map(lambda s: (s, lambda signal, frame: None), signals))

0
asciinema/py.typed Normal file
View File

View File

@@ -1,102 +1,191 @@
import os
import time
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type
import asciinema.asciicast.v2 as v2
import asciinema.pty as pty
import asciinema.term as term
from asciinema.async_worker import async_worker
from . import pty_ as pty # avoid collisions with standard library `pty`
from .asciicast import v2
from .asciicast.v2 import writer as w2
from .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={}):
def record( # pylint: disable=too-many-arguments,too-many-locals
path_: str,
command: Optional[str] = None,
append: bool = False,
idle_time_limit: Optional[float] = None,
record_stdin: bool = False,
title: Optional[str] = None,
command_env: Optional[Dict[str, str]] = None,
capture_env: Optional[List[str]] = None,
writer: Type[w2] = v2.writer,
record_: Callable[..., None] = pty.record,
notify: Callable[[str], None] = lambda _: None,
key_bindings: Optional[Dict[str, Any]] = None,
cols_override: Optional[int] = None,
rows_override: Optional[int] = None,
) -> None:
if command is None:
command = os.environ.get('SHELL') or 'sh'
command = os.environ.get("SHELL", "sh")
if command_env is None:
command_env = os.environ.copy()
command_env['ASCIINEMA_REC'] = '1'
if key_bindings is None:
key_bindings = {}
command_env["ASCIINEMA_REC"] = "1"
if capture_env is None:
capture_env = ['SHELL', 'TERM']
capture_env = ["SHELL", "TERM"]
w, h = term.get_size()
time_offset: float = 0
full_metadata = {
'width': w,
'height': h,
'timestamp': int(time.time())
}
if append and os.stat(path_).st_size > 0:
time_offset = v2.get_duration(path_)
full_metadata.update(metadata or {})
with tty_fds() as (tty_stdin_fd, tty_stdout_fd), async_notifier(
notify
) as _notifier:
get_tty_size = _get_tty_size(
tty_stdout_fd, cols_override, rows_override
)
cols, rows = get_tty_size()
metadata = build_metadata(
cols, rows, idle_time_limit, capture_env, command_env, title
)
if idle_time_limit is not None:
full_metadata['idle_time_limit'] = idle_time_limit
sync_writer = writer(
path_, metadata, append, on_error=_notifier.queue.put
)
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,
with async_writer(sync_writer, time_offset, record_stdin) as _writer:
record_(
["sh", "-c", command],
command_env,
rec_stdin,
time_offset,
n,
key_bindings
_writer,
get_tty_size,
_notifier.notify,
key_bindings,
tty_stdin_fd=tty_stdin_fd,
tty_stdout_fd=tty_stdout_fd,
)
class tty_fds:
def __init__(self) -> None:
self.stdin_file: Optional[TextIO] = None
self.stdout_file: Optional[TextIO] = None
def __enter__(self) -> Tuple[int, int]:
try:
self.stdin_file = open("/dev/tty", "rt", encoding="utf_8")
except OSError:
self.stdin_file = open("/dev/null", "rt", encoding="utf_8")
try:
self.stdout_file = open("/dev/tty", "wt", encoding="utf_8")
except OSError:
self.stdout_file = open("/dev/null", "wt", encoding="utf_8")
return (self.stdin_file.fileno(), self.stdout_file.fileno())
def __exit__(self, type_: str, value: str, traceback: str) -> None:
assert self.stdin_file is not None
assert self.stdout_file is not None
self.stdin_file.close()
self.stdout_file.close()
def build_metadata( # pylint: disable=too-many-arguments
cols: int,
rows: int,
idle_time_limit: Optional[float],
capture_env: List[str],
env: Dict[str, str],
title: Optional[str],
) -> Dict[str, Any]:
metadata: Dict[str, Any] = {
"width": cols,
"height": rows,
"timestamp": int(time.time()),
}
if idle_time_limit is not None:
metadata["idle_time_limit"] = idle_time_limit
metadata["env"] = {var: env.get(var) for var in capture_env}
if title:
metadata["title"] = title
return metadata
class async_writer(async_worker):
def __init__(self, writer, path, metadata, append=False):
def __init__(
self, writer: w2, time_offset: float, record_stdin: bool
) -> None:
async_worker.__init__(self)
self.writer = writer
self.path = path
self.metadata = metadata
self.append = append
self.time_offset = time_offset
self.record_stdin = record_stdin
def write_stdin(self, ts, data):
self.enqueue([ts, 'i', data])
def write_stdin(self, ts: float, data: Any) -> None:
if self.record_stdin:
self.enqueue([ts, "i", data])
def write_stdout(self, ts, data):
self.enqueue([ts, 'o', data])
def write_stdout(self, ts: float, data: Any) -> None:
self.enqueue([ts, "o", data])
def run(self):
with self.writer(self.path, metadata=self.metadata, append=self.append) as w:
def run(self) -> None:
with self.writer as w:
event: Tuple[float, str, Any]
for event in iter(self.queue.get, None):
assert event is not None
ts, etype, data = event
if etype == 'o':
w.write_stdout(ts, data)
elif etype == 'i':
w.write_stdin(ts, data)
if etype == "o":
w.write_stdout(self.time_offset + ts, data)
elif etype == "i":
w.write_stdin(self.time_offset + ts, data)
class async_notifier(async_worker):
def __init__(self, notifier):
def __init__(self, notify: Callable[[str], None]) -> None:
async_worker.__init__(self)
self.notifier = notifier
self._notify = notify
def notify(self, text):
def notify(self, text: str) -> None:
self.enqueue(text)
def perform(self, text):
def perform(self, text: str) -> None:
try:
if self.notifier:
self.notifier.notify(text)
except:
self._notify(text)
except: # pylint: disable=bare-except # noqa: E722
# we catch *ALL* exceptions here because we don't want failed
# notification to crash the recording session
pass
def _get_tty_size(
fd: int, cols_override: Optional[int], rows_override: Optional[int]
) -> Callable[[], Tuple[int, int]]:
if cols_override is not None and rows_override is not None:
def fixed_size() -> Tuple[int, int]:
return (cols_override, rows_override) # type: ignore
return fixed_size
if not os.isatty(fd):
def fallback_size() -> Tuple[int, int]:
return (cols_override or 80, rows_override or 24)
return fallback_size
def size() -> Tuple[int, int]:
cols, rows = os.get_terminal_size(fd)
return (cols_override or cols, rows_override or rows)
return size

View File

@@ -1,42 +0,0 @@
import os
import select
import subprocess
import time
import tty
class raw():
def __init__(self, fd):
self.fd = fd
self.restore = False
def __enter__(self):
try:
self.mode = tty.tcgetattr(self.fd)
tty.setraw(self.fd)
self.restore = True
except tty.error: # This is the same as termios.error
pass
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)
def read_blocking(fd, timeout):
if fd in select.select([fd], [], [], timeout)[0]:
return os.read(fd, 1024)
return b''
def get_size():
try:
return os.get_terminal_size()
except:
return (
int(subprocess.check_output(['tput', 'cols'])),
int(subprocess.check_output(['tput', 'lines']))
)

34
asciinema/tty_.py Normal file
View File

@@ -0,0 +1,34 @@
import os
import select
import termios as tty # avoid `Module "tty" has no attribute ...` errors
from time import sleep
from tty import setraw
from typing import IO, Any, List, Optional, Union
class raw:
def __init__(self, fd: Union[IO[str], int]) -> None:
self.fd = fd
self.restore: bool = False
self.mode: Optional[List[Any]] = None
def __enter__(self) -> None:
try:
self.mode = tty.tcgetattr(self.fd)
setraw(self.fd)
self.restore = True
except tty.error: # this is `termios.error`
pass
def __exit__(self, type_: str, value: str, traceback: str) -> None:
if self.restore:
sleep(0.01) # give the terminal time to send answerbacks
assert isinstance(self.mode, list)
tty.tcsetattr(self.fd, tty.TCSAFLUSH, self.mode)
def read_blocking(fd: int, timeout: Any) -> bytes:
if fd in select.select([fd], [], [], timeout)[0]:
return os.read(fd, 1024)
return b""

View File

@@ -1,94 +1,124 @@
import codecs
import sys
import uuid
import io
import base64
import http
from urllib.request import Request, urlopen
import io
import sys
from base64 import b64encode
from http.client import HTTPResponse
from typing import Any, Dict, Generator, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from uuid import uuid4
from .http_adapter import HTTPConnectionError
class MultipartFormdataEncoder:
def __init__(self):
self.boundary = uuid.uuid4().hex
self.content_type = 'multipart/form-data; boundary={}'.format(self.boundary)
def __init__(self) -> None:
self.boundary = uuid4().hex
self.content_type = f"multipart/form-data; boundary={self.boundary}"
@classmethod
def u(cls, s):
def u(cls, s: Any) -> Any:
if sys.hexversion >= 0x03000000 and isinstance(s, bytes):
s = s.decode('utf-8')
s = s.decode("utf-8")
return s
def iter(self, fields, files):
def iter(
self, fields: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
) -> Generator[Tuple[bytes, int], None, None]:
"""
fields is a dict of {name: value} for regular form fields.
files is a dict of {name: (filename, file-type)} for data to be uploaded as files
Yield body's chunk as bytes
fields: {name: value} for regular form fields.
files: {name: (filename, file-type)} for data to be uploaded as files
yield body's chunk as bytes
"""
encoder = codecs.getencoder('utf-8')
encoder = codecs.getencoder("utf-8")
for (key, value) in fields.items():
key = self.u(key)
yield encoder('--{}\r\n'.format(self.boundary))
yield encoder(self.u('Content-Disposition: form-data; name="{}"\r\n').format(key))
yield encoder('\r\n')
if isinstance(value, int) or isinstance(value, float):
yield encoder(f"--{self.boundary}\r\n")
yield encoder(
self.u(f'content-disposition: form-data; name="{key}"\r\n')
)
yield encoder("\r\n")
if isinstance(value, (int, float)):
value = str(value)
yield encoder(self.u(value))
yield encoder('\r\n')
yield encoder("\r\n")
for (key, filename_and_f) in files.items():
filename, f = filename_and_f
key = self.u(key)
filename = self.u(filename)
yield encoder('--{}\r\n'.format(self.boundary))
yield encoder(self.u('Content-Disposition: form-data; name="{}"; filename="{}"\r\n').format(key, filename))
yield encoder('Content-Type: application/octet-stream\r\n')
yield encoder('\r\n')
yield encoder(f"--{self.boundary}\r\n")
yield encoder(
self.u(
"content-disposition: form-data"
f'; name="{key}"'
f'; filename="{filename}"\r\n'
)
)
yield encoder("content-type: application/octet-stream\r\n")
yield encoder("\r\n")
data = f.read()
yield (data, len(data))
yield encoder('\r\n')
yield encoder('--{}--\r\n'.format(self.boundary))
yield encoder("\r\n")
yield encoder(f"--{self.boundary}--\r\n")
def encode(self, fields, files):
def encode(
self, fields: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
) -> Tuple[str, bytes]:
body = io.BytesIO()
for chunk, chunk_len in self.iter(fields, files):
for chunk, _ in self.iter(fields, files):
body.write(chunk)
return self.content_type, body.getvalue()
class URLLibHttpAdapter:
class URLLibHttpAdapter: # pylint: disable=too-few-public-methods
def post( # pylint: disable=too-many-arguments,too-many-locals
self,
url: str,
fields: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Tuple[str, Any]]] = None,
headers: Optional[Dict[str, str]] = None,
username: Optional[str] = None,
password: Optional[str] = None,
) -> Tuple[Any, Optional[Dict[str, str]], bytes]:
# avoid dangerous mutable default arguments
if fields is None:
fields = {}
if files is None:
files = {}
if headers is None:
headers = {}
def post(self, url, fields={}, files={}, headers={}, username=None, password=None):
content_type, body = MultipartFormdataEncoder().encode(fields, files)
headers = headers.copy()
headers["Content-Type"] = content_type
headers["content-type"] = content_type
if password:
auth = "%s:%s" % (username, password)
encoded_auth = base64.b64encode(bytes(auth, "utf-8"))
headers["Authorization"] = b"Basic " + encoded_auth
encoded_auth = b64encode(
f"{username}:{password}".encode("utf_8")
).decode("utf_8")
headers["authorization"] = f"Basic {encoded_auth}"
request = Request(url, data=body, headers=headers, method="POST")
try:
response = urlopen(request)
status = response.status
headers = self._parse_headers(response)
body = response.read().decode('utf-8')
with urlopen(request) as response:
status = response.status
headers = self._parse_headers(response)
body = response.read().decode("utf-8")
except HTTPError as e:
status = e.code
headers = {}
body = e.read().decode('utf-8')
body = e.read()
except (http.client.RemoteDisconnected, URLError) as e:
raise HTTPConnectionError(str(e))
raise HTTPConnectionError(str(e)) from e
return (status, headers, body)
def _parse_headers(self, response):
headers = {}
for k, v in response.getheaders():
headers[k.lower()] = v
@staticmethod
def _parse_headers(response: HTTPResponse) -> Dict[str, str]:
headers = {k.lower(): v for k, v in response.getheaders()}
return headers

View File

@@ -177,8 +177,7 @@ easily fixed in the old format:
Due to file structure change (standard JSON => newline-delimited JSON) version 2
is not backwards compatible with version 1. Support for v2 has been added in:
* [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0
(to be released, currently on development branch)
* [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0.0
* [asciinema web player](https://github.com/asciinema/asciinema-player) - 2.6.0
* [asciinema server](https://github.com/asciinema/asciinema-server) - v20171105
tag in git repository

View File

@@ -2,4 +2,3 @@ VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"`
asciinema.1: asciinema.1.md
pandoc asciinema.1.md -s -t man -o asciinema.1 -V header:"Version $(VERSION), `date +%Y-%m-%d`"

View File

@@ -1,225 +1,241 @@
.\" Automatically generated by Pandoc 2.1.3
.\" Automatically generated by Pandoc 2.18
.\"
.TH "ASCIINEMA" "1" "" "Version 2.0.1" "asciinema"
.\" Define V font for inline verbatim, using C font in formats
.\" that render this, and otherwise B font.
.ie "\f[CB]x\f[]"x" \{\
. ftr V B
. ftr VI BI
. ftr VB B
. ftr VBI BI
.\}
.el \{\
. ftr V CR
. ftr VI CI
. ftr VB CB
. ftr VBI CBI
.\}
.TH "ASCIINEMA" "1" "" "Version 2.0.1" "Version 2.1.0, 2022-05-07"
.hy
.SH NAME
.PP
\f[B]asciinema\f[] \- terminal session recorder
\f[B]asciinema\f[R] - terminal session recorder
.SH SYNOPSIS
.PP
\f[B]asciinema \-\-version\f[]
\f[B]asciinema --version\f[R]
.PD 0
.P
.PD
\f[B]asciinema\f[] \f[I]command\f[] [\f[I]options\f[]] [\f[I]args\f[]]
\f[B]asciinema\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
[\f[I]args\f[R]]
.SH DESCRIPTION
.PP
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.
.SH COMMANDS
.PP
asciinema is composed of multiple commands, similar to \f[C]git\f[],
\f[C]apt\-get\f[] or \f[C]brew\f[].
asciinema is composed of multiple commands, similar to \f[V]git\f[R],
\f[V]apt-get\f[R] or \f[V]brew\f[R].
.PP
When you run \f[B]asciinema\f[] with no arguments help message is
When you run \f[B]asciinema\f[R] with no arguments a help message is
displayed, listing all available commands with their options.
.SS rec [\f[I]filename\f[]]
.SS rec [\f[I]filename\f[R]]
.PP
Record terminal session.
.PP
By running \f[B]asciinema rec [filename]\f[] you start a new recording
By running \f[B]asciinema rec [filename]\f[R] you start a new recording
session.
The command (process) that is recorded can be specified with
\f[B]\-c\f[] option (see below), and defaults to \f[B]$SHELL\f[] which
\f[B]-c\f[R] option (see below), and defaults to \f[B]$SHELL\f[R] which
is what you want in most cases.
.PP
You can temporarily pause recording of terminal by pressing Ctrl+\[rs].
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 Ctrl+\[rs] again.
.PP
Recording finishes when you exit the shell (hit Ctrl+D or type
\f[C]exit\f[]).
\f[V]exit\f[R]).
If the recorded process is not a shell then recording finishes when the
process exits.
.PP
If the \f[I]filename\f[] argument is omitted then (after asking for
If the \f[I]filename\f[R] argument is omitted then (after asking for
confirmation) the resulting asciicast is uploaded to
asciinema\-server (https://github.com/asciinema/asciinema-server) (by
asciinema-server (https://github.com/asciinema/asciinema-server) (by
default to asciinema.org), where it can be watched and shared.
.PP
If the \f[I]filename\f[] argument is given then the resulting recording
(called asciicast (doc/asciicast-v2.md)) is saved to a local file.
It can later be replayed with \f[B]asciinema play <filename>\f[] and/or
uploaded to asciinema server with \f[B]asciinema upload <filename>\f[].
If the \f[I]filename\f[R] argument is given then the resulting recording
(called asciicast) is saved to a local file.
It can later be replayed with \f[B]asciinema play <filename>\f[R] and/or
uploaded to asciinema server with \f[B]asciinema upload <filename>\f[R].
.PP
\f[B]ASCIINEMA_REC=1\f[] is added to recorded process environment
\f[B]ASCIINEMA_REC=1\f[R] is added to recorded process environment
variables.
This can be used by your shell's config file (\f[C]\&.bashrc\f[],
\f[C]\&.zshrc\f[]) to alter the prompt or play a sound when the shell is
This can be used by your shell\[cq]s config file (\f[V].bashrc\f[R],
\f[V].zshrc\f[R]) to alter the prompt or play a sound when the shell is
being recorded.
.TP
.B Available options:
Available options:
\
.RS
.TP
.B \f[C]\-\-stdin\f[]
\f[V]--stdin\f[R]
Enable stdin (keyboard) recording (see below)
.RS
.RE
.TP
.B \f[C]\-\-append\f[]
\f[V]--append\f[R]
Append to existing recording
.RS
.RE
.TP
.B \f[C]\-\-raw\f[]
\f[V]--raw\f[R]
Save raw STDOUT output, without timing information or other metadata
.RS
.RE
.TP
.B \f[C]\-\-overwrite\f[]
\f[V]--overwrite\f[R]
Overwrite the recording if it already exists
.RS
.RE
.TP
.B \f[C]\-c,\ \-\-command=<command>\f[]
Specify command to record, defaults to \f[B]$SHELL\f[]
.RS
.RE
\f[V]-c, --command=<command>\f[R]
Specify command to record, defaults to \f[B]$SHELL\f[R]
.TP
.B \f[C]\-e,\ \-\-env=<var\-names>\f[]
\f[V]-e, --env=<var-names>\f[R]
List of environment variables to capture, defaults to
\f[B]SHELL,TERM\f[]
.RS
.RE
\f[B]SHELL,TERM\f[R]
.TP
.B \f[C]\-t,\ \-\-title=<title>\f[]
\f[V]-t, --title=<title>\f[R]
Specify the title of the asciicast
.RS
.RE
.TP
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[]
Limit recorded terminal inactivity to max \f[C]<sec>\f[] seconds
.RS
.RE
\f[V]-i, --idle-time-limit=<sec>\f[R]
Limit recorded terminal inactivity to max \f[V]<sec>\f[R] seconds
.TP
.B \f[C]\-y,\ \-\-yes\f[]
\f[V]--cols=<n>\f[R]
Override terminal columns for recorded process
.TP
\f[V]--rows=<n>\f[R]
Override terminal rows for recorded process
.TP
\f[V]-y, --yes\f[R]
Answer \[lq]yes\[rq] to all prompts (e.g.\ upload confirmation)
.RS
.RE
.TP
.B \f[C]\-q,\ \-\-quiet\f[]
Be quiet, suppress all notices/warnings (implies \f[B]\-y\f[])
.RS
.RE
\f[V]-q, --quiet\f[R]
Be quiet, suppress all notices/warnings (implies \f[B]-y\f[R])
.RE
.PP
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.
asciinema\-player (https://github.com/asciinema/asciinema-player)) to
asciinema-player (https://github.com/asciinema/asciinema-player)) to
display 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 \f[B]\[en]stdin\f[] option.
.SS play <\f[I]filename\f[]>
Because it\[cq]s basically a key-logging (scoped to a single shell
instance), it\[cq]s disabled by default, and has to be explicitly
enabled via \f[B]\[en]stdin\f[R] option.
.SS play <\f[I]filename\f[R]>
.PP
Replay recorded asciicast in a terminal.
.PP
This command replays given asciicast (as recorded by \f[B]rec\f[]
This command replays a given asciicast (as recorded by \f[B]rec\f[R]
command) directly in your terminal.
.PP
Following keyboard shortcuts are available:
.IP
.nf
\f[C]
Space\ \-\ toggle\ pause,
\&.\ \-\ step\ through\ a\ recording\ a\ frame\ at\ a\ time\ (when\ paused),
Ctrl+C\ \-\ exit.
\f[]
.fi
The asciicast can be read from a file or from \f[I]\f[VI]stdin\f[I]\f[R]
(`-'):
.PP
Playing from a local file:
.IP
.nf
\f[C]
asciinema\ play\ /path/to/asciicast.cast
\f[]
asciinema play /path/to/asciicast.cast
\f[R]
.fi
.PP
Playing from HTTP(S) URL:
.IP
.nf
\f[C]
asciinema\ play\ https://asciinema.org/a/22124.cast
asciinema\ play\ http://example.com/demo.cast
\f[]
asciinema play https://asciinema.org/a/22124.cast
asciinema play http://example.com/demo.cast
\f[R]
.fi
.PP
Playing from asciicast page URL (requires
\f[C]<link\ rel="alternate"\ type="application/x\-asciicast"\ href="/my/ascii.cast">\f[]
in page's HTML):
\f[V]<link rel=\[dq]alternate\[dq] type=\[dq]application/x-asciicast\[dq] href=\[dq]/my/ascii.cast\[dq]>\f[R]
in page\[cq]s HTML):
.IP
.nf
\f[C]
asciinema\ play\ https://asciinema.org/a/22124
asciinema\ play\ http://example.com/blog/post.html
\f[]
asciinema play https://asciinema.org/a/22124
asciinema play http://example.com/blog/post.html
\f[R]
.fi
.PP
Playing from stdin:
.IP
.nf
\f[C]
cat\ /path/to/asciicast.cast\ |\ asciinema\ play\ \-
ssh\ user\@host\ cat\ asciicast.cast\ |\ asciinema\ play\ \-
\f[]
cat /path/to/asciicast.cast | asciinema play -
ssh user\[at]host cat asciicast.cast | asciinema play -
\f[R]
.fi
.PP
Playing from IPFS:
.IP
.nf
\f[C]
asciinema\ play\ dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
\f[]
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
\f[R]
.fi
.TP
.B Available options:
Available options:
\
.RS
.TP
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[]
Limit replayed terminal inactivity to max \f[C]<sec>\f[] seconds
.RS
\f[V]-i, --idle-time-limit=<sec>\f[R]
Limit replayed terminal inactivity to max \f[V]<sec>\f[R] seconds (can
be fractional)
.TP
\f[V]-s, --speed=<factor>\f[R]
Playback speed (can be fractional)
.RE
.TP
.B \f[C]\-s,\ \-\-speed=<factor>\f[]
Playback speed (can be fractional)
While playing the following keyboard shortcuts are available:
\
.RS
.TP
\f[I]\f[VI]Space\f[I]\f[R]
Toggle pause
.TP
\f[I]\f[VI].\f[I]\f[R]
Step through a recording a frame at a time (when paused)
.TP
\f[I]\f[VI]Ctrl+C\f[I]\f[R]
Exit
.RE
.RE
.SS cat <\f[I]filename\f[]>
.PP
Recommendation: run `asciinema play' in a terminal of dimensions not
smaller than the one used for recording as there\[cq]s no
\[lq]transcoding\[rq] of control sequences for the new terminal size.
.SS cat <\f[I]filename\f[R]>
.PP
Print full output of recorded asciicast to a terminal.
.PP
While \f[B]asciinema play \f[] replays the recorded session using timing
information saved in the asciicast, \f[B]asciinema cat \f[] dumps the
full output (including all escape sequences) to a terminal immediately.
While \f[B]asciinema play \f[R] replays the recorded session using
timing information saved in the asciicast, \f[B]asciinema cat \f[R]
dumps the full output (including all escape sequences) to a terminal
immediately.
.PP
\f[B]asciinema cat existing.cast >output.txt\f[] gives the same result
as recording via \f[B]asciinema rec \-\-raw output.txt\f[].
.SS upload
\f[B]asciinema cat existing.cast >output.txt\f[R] gives the same result
as recording via \f[B]asciinema rec --raw output.txt\f[R].
.SS upload <\f[I]filename\f[R]>
.PP
Upload recorded asciicast to asciinema.org site.
.PP
This command uploads given asciicast (recorded by \f[B]rec\f[] command)
This command uploads given asciicast (recorded by \f[B]rec\f[R] command)
to asciinema.org, where it can be watched and shared.
.PP
\f[B]asciinema rec demo.cast\f[] + \f[B]asciinema play demo.cast\f[] +
\f[B]asciinema upload demo.cast\f[] is a nice combo if you want to
\f[B]asciinema rec demo.cast\f[R] + \f[B]asciinema play demo.cast\f[R] +
\f[B]asciinema upload demo.cast\f[R] is a nice combo if you want to
review an asciicast before publishing it on asciinema.org.
.SS auth
.PP
Link your install ID with your asciinema.org user account.
Link and manage your install ID with your asciinema.org user account.
.PP
If you want to manage your recordings (change title/theme, delete) at
asciinema.org you need to link your \[lq]install ID\[rq] with
asciinema.org you need to link your \[lq]install ID\[rq] with your
asciinema.org user account.
.PP
This command displays the URL to open in a web browser to do that.
@@ -228,16 +244,25 @@ You may be 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 \f[B]$HOME/.config/asciinema/install\-id\f[].
It's purpose is to connect local machine with uploaded recordings, so
they can later be associated with asciinema.org account.
at \f[B]$HOME/.config/asciinema/install-id\f[R].
It\[cq]s 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.
.PP
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 \f[B]asciinema auth\f[] on
all of those machines.
account you use asciinema on.
So in order to keep all recordings under a single asciinema.org account
you need to run \f[B]asciinema auth\f[R] on all of those machines.
If you\[cq]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.
.PP
While you CAN synchronize your config file (which keeps the API token)
across all your machines so all use the same token, that\[cq]s not
necessary.
You can assign new tokens to your account from as many machines as you
want.
.PP
Note: asciinema versions prior to 2.0 confusingly referred to install ID
as \[lq]API token\[rq].
@@ -247,28 +272,36 @@ Record your first session:
.IP
.nf
\f[C]
asciinema\ rec\ first.cast
\f[]
asciinema rec first.cast
\f[R]
.fi
.PP
End your session:
.IP
.nf
\f[C]
exit
\f[R]
.fi
.PP
Now replay it with double speed:
.IP
.nf
\f[C]
asciinema\ play\ \-s\ 2\ first.cast
\f[]
asciinema play -s 2 first.cast
\f[R]
.fi
.PP
Or with normal speed but with idle time limited to 2 seconds:
.IP
.nf
\f[C]
asciinema\ play\ \-i\ 2\ first.cast
\f[]
asciinema play -i 2 first.cast
\f[R]
.fi
.PP
You can pass \f[B]\-i 2\f[] to \f[B]asciinema rec\f[] as well, to set it
permanently on a recording.
You can pass \f[B]-i 2\f[R] to \f[B]asciinema rec\f[R] as well, to set
it permanently on a recording.
Idle time limiting makes the recordings much more interesting to watch,
try it.
.PP
@@ -276,12 +309,12 @@ If you want to watch and share it on the web, upload it:
.IP
.nf
\f[C]
asciinema\ upload\ first.cast
\f[]
asciinema upload first.cast
\f[R]
.fi
.PP
The above uploads it to <https://asciinema.org>, which is a default
asciinema\-server (<https://github.com/asciinema/asciinema-server>)
asciinema-server (<https://github.com/asciinema/asciinema-server>)
instance, and prints a secret link you can use to watch your recording
in a web browser.
.PP
@@ -289,33 +322,79 @@ You can record and upload in one step by omitting the filename:
.IP
.nf
\f[C]
asciinema\ rec
\f[]
asciinema rec
\f[R]
.fi
.PP
You'll be asked to confirm the upload when the recording is done, so
You\[cq]ll be asked to confirm the upload when the recording is done, so
nothing is sent anywhere without your consent.
.SH ENVIRONMENT
.SS Tricks
.TP
.B \f[B]ASCIINEMA_API_URL\f[]
This variable allows overriding asciinema\-server URL (which defaults to
https://asciinema.org) in case you're running your own asciinema\-server
instance.
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:
.RS
.IP
.nf
\f[C]
asciinema rec initial.cast
\f[R]
.fi
.PP
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 \[lq]My fancy title\[rq]::
.IP
.nf
\f[C]
asciinema rec -c \[dq]asciinema play -s 5 -i 2 initial.cast\[dq] -t \[dq]My fancy title\[dq] final.cast
\f[R]
.fi
.RE
.TP
.B \f[B]ASCIINEMA_CONFIG_HOME\f[]
Play from \f[I]\f[VI]stdin\f[I]\f[R]:
\
.RS
.PP
cat /path/to/asciicast.json | asciinema play -
.RE
.TP
Play file from remote host accessible with SSH:
\
.RS
.PP
ssh user\[at]host cat /path/to/asciicat.json | asciinema play -
.RE
.SH ENVIRONMENT
.TP
\f[B]ASCIINEMA_API_URL\f[R]
This variable allows overriding asciinema-server URL (which defaults to
https://asciinema.org) in case you\[cq]re running your own
asciinema-server instance.
.TP
\f[B]ASCIINEMA_CONFIG_HOME\f[R]
This variable allows overriding config directory location.
Default location is $XDG_CONFIG_HOME/asciinema (when $XDG_CONFIG_HOME is
set) or $HOME/.config/asciinema.
.RS
.RE
.SH BUGS
.PP
See GitHub Issues: <https://github.com/asciinema/asciinema/issues>
.SH MORE RESOURCES
.PP
More documentation is available on the asciicast.org website and its
GitHub wiki:
.IP \[bu] 2
Web: asciinema.org/docs/ (https://asciinema.org/docs/)
.IP \[bu] 2
Wiki:
github.com/asciinema/asciinema/wiki (https://github.com/asciinema/asciinema/wiki)
.IP \[bu] 2
IRC: Channel on Libera.Chat (https://web.libera.chat/gamja/#asciinema)
.IP \[bu] 2
Twitter: \[at]asciinema (https://twitter.com/asciinema)
.SH AUTHORS
.PP
asciinema's lead developer is Marcin Kulik.
asciinema\[cq]s lead developer is Marcin Kulik.
.PP
For a list of all contributors look here:
<https://github.com/asciinema/asciinema/contributors>

View File

@@ -43,9 +43,9 @@ 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
<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
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.
@@ -95,6 +95,12 @@ Available options:
`-i, --idle-time-limit=<sec>`
: Limit recorded terminal inactivity to max `<sec>` seconds
`--cols=<n>`
: Override terminal columns for recorded process
`--rows=<n>`
: Override terminal rows for recorded process
`-y, --yes`
: Answer "yes" to all prompts (e.g. upload confirmation)
@@ -325,6 +331,10 @@ 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)
<<<<<<< HEAD
=======
* IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema)
>>>>>>> develop
* Twitter: [@asciinema](https://twitter.com/asciinema)
@@ -336,4 +346,3 @@ 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.

38
pyproject.toml Normal file
View File

@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 79
target-version = ["py38"]
[tool.isort]
line_length = 79
profile = "black"
multi_line_output = 3
[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
no_implicit_reexport = true
show_error_context = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true
exclude = []
[tool.pylint."MESSAGES CONTROL"]
disable = [
"invalid-name",
"missing-class-docstring",
"missing-function-docstring",
"missing-module-docstring",
]
min-similarity-lines = 7

View File

@@ -1,6 +1,59 @@
[metadata]
description-file = README.md
name = asciinema
version = 2.2.0
author = Marcin Kulik
author_email = m@ku1ik.com
url = https://asciinema.org
download_url =
https://github.com/asciinema/asciinema/archive/v%(version)s.tar.gz
description = Terminal session recorder
description_file = README.md
license = GNU GPLv3
license_file = LICENSE
long_description = file: README.md
long_description_content_type = text/markdown; charset=UTF-8
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Console
Intended Audience :: Developers
Intended Audience :: System Administrators
License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Natural Language :: English
Programming Language :: Python
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Topic :: System :: Shells
Topic :: Terminals
Topic :: Utilities
[options]
include_package_data = True
packages =
asciinema
asciinema.asciicast
asciinema.commands
install_requires =
[options.package_data]
asciinema = data/*.png
[options.entry_points]
console_scripts =
asciinema = asciinema.__main__:main
[options.data_files]
share/doc/asciinema =
CHANGELOG.md
CODE_OF_CONDUCT.md
CONTRIBUTING.md
README.md
doc/asciicast-v1.md
doc/asciicast-v2.md
share/man/man1 =
man/asciinema.1
[pycodestyle]
ignore = E501,E402,E722

View File

@@ -1,58 +0,0 @@
import asciinema
import sys
from setuptools import setup
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 = []
with open('README.md', encoding='utf8') as file:
long_description = file.read()
setup(
name='asciinema',
version=asciinema.__version__,
packages=['asciinema', 'asciinema.commands', 'asciinema.asciicast'],
license='GNU GPLv3',
description='Terminal session recorder',
long_description=long_description,
long_description_content_type='text/markdown',
author=asciinema.__author__,
author_email='m@ku1ik.com',
url='https://asciinema.org',
download_url=(url_template % asciinema.__version__),
entry_points={
'console_scripts': [
'asciinema = asciinema.__main__:main',
],
},
package_data={'asciinema': ['data/*.png']},
data_files=[('share/doc/asciinema', ['CHANGELOG.md',
'CODE_OF_CONDUCT.md',
'CONTRIBUTING.md',
'README.md',
'doc/asciicast-v1.md',
'doc/asciicast-v2.md']),
('share/man/man1', ['man/asciinema.1'])],
install_requires=requirements,
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Topic :: System :: Shells',
'Topic :: Terminals',
'Topic :: Utilities'
],
)

View File

@@ -1,24 +1,28 @@
from ..test_helper import Test
import asciinema.asciicast.v2 as v2
import tempfile
import json
import tempfile
from asciinema.asciicast import v2
from ..test_helper import Test
class TestWriter(Test):
def test_writing(self):
@staticmethod
def test_writing() -> None:
_file, path = tempfile.mkstemp()
with v2.writer(path, width=80, height=24) as w:
w.write_stdout(1, 'x') # ensure it supports both str and bytes
w.write_stdout(2, bytes.fromhex('78 c5 bc c3 b3 c5'))
w.write_stdout(3, bytes.fromhex('82 c4 87'))
w.write_stdout(4, bytes.fromhex('78 78'))
w.write_stdout(1, "x") # ensure it supports both str and bytes
w.write_stdout(2, bytes.fromhex("78 c5 bc c3 b3 c5"))
w.write_stdout(3, bytes.fromhex("82 c4 87"))
w.write_stdout(4, bytes.fromhex("78 78"))
with open(path, 'r') as f:
lines = list(map(json.loads, f.read().strip().split('\n')))
assert lines == [{"version": 2, "width": 80, "height": 24},
[1, "o", "x"],
[2, "o", "xżó"],
[3, "o", "łć"],
[4, "o", "xx"]], 'got:\n\n%s' % lines
with open(path, "rt", encoding="utf_8") as f:
lines = list(map(json.loads, f.read().strip().split("\n")))
assert lines == [
{"version": 2, "width": 80, "height": 24},
[1, "o", "x"],
[2, "o", "xżó"],
[3, "o", "łć"],
[4, "o", "xx"],
], f"got:\n\n{lines}"

View File

@@ -1,208 +1,218 @@
from nose.tools import assert_equal, assert_raises
import os
import os.path as path
import tempfile
import re
import tempfile
from os import path
from typing import Dict, Optional
import asciinema.config as cfg
from asciinema.config import Config
def create_config(content=None, env={}):
dir = tempfile.mkdtemp()
def create_config(
content: Optional[str] = None, env: Optional[Dict[str, str]] = None
) -> Config:
# avoid redefining `dir` builtin
dir_ = tempfile.mkdtemp()
if content:
path = dir + '/config'
with open(path, 'w') as f:
# avoid redefining `os.path`
path_ = f"{dir_}/config"
with open(path_, "wt", encoding="utf_8") as f:
f.write(content)
return cfg.Config(dir, env)
return cfg.Config(dir_, env)
def read_install_id(install_id_path):
with open(install_id_path, 'r') as f:
def read_install_id(install_id_path: str) -> str:
with open(install_id_path, "rt", encoding="utf_8") as f:
return f.read().strip()
def test_upgrade_no_config_file():
def test_upgrade_no_config_file() -> None:
config = create_config()
config.upgrade()
install_id = read_install_id(config.install_id_path)
assert re.match('^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}', install_id)
assert_equal(install_id, config.install_id)
assert re.match("^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}", install_id)
assert install_id == config.install_id
assert not path.exists(config.config_file_path)
# it must not change after another upgrade
config.upgrade()
assert_equal(read_install_id(config.install_id_path), install_id)
assert read_install_id(config.install_id_path) == install_id
def test_upgrade_config_file_with_api_token():
def test_upgrade_config_file_with_api_token() -> None:
config = create_config("[api]\ntoken = foo-bar-baz")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
assert config.install_id == "foo-bar-baz"
assert not path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
def test_upgrade_config_file_with_api_token_and_more():
config = create_config("[api]\ntoken = foo-bar-baz\nurl = http://example.com")
def test_upgrade_config_file_with_api_token_and_more() -> None:
config = create_config(
"[api]\ntoken = foo-bar-baz\nurl = http://example.com"
)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert_equal(config.api_url, 'http://example.com')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
assert config.install_id == "foo-bar-baz"
assert config.api_url == "http://example.com"
assert path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
def test_upgrade_config_file_with_user_token():
def test_upgrade_config_file_with_user_token() -> None:
config = create_config("[user]\ntoken = foo-bar-baz")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
assert config.install_id == "foo-bar-baz"
assert not path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
def test_upgrade_config_file_with_user_token_and_more():
config = create_config("[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com")
def test_upgrade_config_file_with_user_token_and_more() -> None:
config = create_config(
"[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com"
)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert_equal(config.api_url, 'http://example.com')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
assert config.install_id == "foo-bar-baz"
assert config.api_url == "http://example.com"
assert path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert read_install_id(config.install_id_path) == "foo-bar-baz"
def test_default_api_url():
config = create_config('')
assert_equal('https://asciinema.org', config.api_url)
def test_default_api_url() -> None:
config = create_config("")
assert config.api_url == "https://asciinema.org"
def test_default_record_stdin():
config = create_config('')
assert_equal(False, config.record_stdin)
def test_default_record_stdin() -> None:
config = create_config("")
assert config.record_stdin is False
def test_default_record_command():
config = create_config('')
assert_equal(None, config.record_command)
def test_default_record_command() -> None:
config = create_config("")
assert config.record_command is None
def test_default_record_env():
config = create_config('')
assert_equal('SHELL,TERM', config.record_env)
def test_default_record_env() -> None:
config = create_config("")
assert config.record_env == "SHELL,TERM"
def test_default_record_idle_time_limit():
config = create_config('')
assert_equal(None, config.record_idle_time_limit)
def test_default_record_idle_time_limit() -> None:
config = create_config("")
assert config.record_idle_time_limit is None
def test_default_record_yes():
config = create_config('')
assert_equal(False, config.record_yes)
def test_default_record_yes() -> None:
config = create_config("")
assert config.record_yes is False
def test_default_record_quiet():
config = create_config('')
assert_equal(False, config.record_quiet)
def test_default_record_quiet() -> None:
config = create_config("")
assert config.record_quiet is False
def test_default_play_idle_time_limit():
config = create_config('')
assert_equal(None, config.play_idle_time_limit)
def test_default_play_idle_time_limit() -> None:
config = create_config("")
assert config.play_idle_time_limit is None
def test_api_url():
def test_api_url() -> None:
config = create_config("[api]\nurl = http://the/url")
assert_equal('http://the/url', config.api_url)
assert config.api_url == "http://the/url"
def test_api_url_when_override_set():
config = create_config("[api]\nurl = http://the/url", {
'ASCIINEMA_API_URL': 'http://the/url2'})
assert_equal('http://the/url2', config.api_url)
def test_api_url_when_override_set() -> None:
config = create_config(
"[api]\nurl = http://the/url", {"ASCIINEMA_API_URL": "http://the/url2"}
)
assert config.api_url == "http://the/url2"
def test_record_command():
command = 'bash -l'
config = create_config("[record]\ncommand = %s" % command)
assert_equal(command, config.record_command)
def test_record_command() -> None:
command = "bash -l"
config = create_config(f"[record]\ncommand = {command}")
assert config.record_command == command
def test_record_stdin():
def test_record_stdin() -> None:
config = create_config("[record]\nstdin = yes")
assert_equal(True, config.record_stdin)
assert config.record_stdin is True
def test_record_env():
def test_record_env() -> None:
config = create_config("[record]\nenv = FOO,BAR")
assert_equal('FOO,BAR', config.record_env)
assert config.record_env == "FOO,BAR"
def test_record_idle_time_limit():
def test_record_idle_time_limit() -> None:
config = create_config("[record]\nidle_time_limit = 2.35")
assert_equal(2.35, config.record_idle_time_limit)
assert config.record_idle_time_limit == 2.35
config = create_config("[record]\nmaxwait = 2.35")
assert_equal(2.35, config.record_idle_time_limit)
assert config.record_idle_time_limit == 2.35
def test_record_yes():
yes = 'yes'
config = create_config("[record]\nyes = %s" % yes)
assert_equal(True, config.record_yes)
def test_record_yes() -> None:
yes = "yes"
config = create_config(f"[record]\nyes = {yes}")
assert config.record_yes is True
def test_record_quiet():
quiet = 'yes'
config = create_config("[record]\nquiet = %s" % quiet)
assert_equal(True, config.record_quiet)
def test_record_quiet() -> None:
quiet = "yes"
config = create_config(f"[record]\nquiet = {quiet}")
assert config.record_quiet is True
def test_play_idle_time_limit():
def test_play_idle_time_limit() -> None:
config = create_config("[play]\nidle_time_limit = 2.35")
assert_equal(2.35, config.play_idle_time_limit)
assert config.play_idle_time_limit == 2.35
config = create_config("[play]\nmaxwait = 2.35")
assert_equal(2.35, config.play_idle_time_limit)
assert config.play_idle_time_limit == 2.35
def test_notifications_enabled():
config = create_config('')
assert_equal(True, config.notifications_enabled)
def test_notifications_enabled() -> None:
config = create_config("")
assert config.notifications_enabled is True
config = create_config("[notifications]\nenabled = yes")
assert_equal(True, config.notifications_enabled)
assert config.notifications_enabled is True
config = create_config("[notifications]\nenabled = no")
assert_equal(False, config.notifications_enabled)
assert config.notifications_enabled is False
def test_notifications_command():
config = create_config('')
assert_equal(None, config.notifications_command)
def test_notifications_command() -> None:
config = create_config("")
assert config.notifications_command is None
config = create_config('[notifications]\ncommand = tmux display-message "$TEXT"')
assert_equal('tmux display-message "$TEXT"', config.notifications_command)
config = create_config(
'[notifications]\ncommand = tmux display-message "$TEXT"'
)
assert config.notifications_command == 'tmux display-message "$TEXT"'

View File

@@ -1,22 +1,38 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
path_to_self="${BASH_SOURCE[0]}"
tests_dir="$(cd "$(dirname "$path_to_self")" && pwd)"
readonly DISTROS=(
'arch'
'alpine'
'centos'
'debian'
'fedora'
'ubuntu'
)
test() {
printf "\e[1;32mTesting on $1...\e[0m\n"
echo
readonly DOCKER='docker'
docker build -t asciinema/asciinema:$1 -f tests/distros/Dockerfile.$1 .
docker run --rm -ti asciinema/asciinema:$1 tests/integration.sh
# do not redefine builtin `test`
test_() {
local -r tag="${1}"
local -ra docker_opts=(
"--tag=asciinema/asciinema:${tag}"
"--file=tests/distros/Dockerfile.${tag}"
)
printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}"
# shellcheck disable=SC2068
"${DOCKER}" build ${docker_opts[@]} .
"${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh
}
test ubuntu
test debian
test fedora
test centos
echo
printf "\e[1;32mAll tests passed.\e[0m\n"
for distro in "${DISTROS[@]}"; do
test_ "${distro}"
done
printf "\n\e[1;32mAll tests passed.\e[0m\n"

View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.3
FROM docker.io/library/alpine:3.15
# https://github.com/actions/runner/issues/241
RUN apk --no-cache add bash ca-certificates make python3 util-linux
WORKDIR /usr/src/app
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.3
FROM docker.io/library/archlinux:latest
RUN pacman-key --init \
&& pacman --sync --refresh --sysupgrade --noconfirm make python3 \
&& printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \
&& locale-gen \
&& pacman --sync --clean --clean --noconfirm
WORKDIR /usr/src/app
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

@@ -1,10 +1,18 @@
FROM centos:7
# syntax=docker/dockerfile:1.3
FROM docker.io/library/centos:7
RUN yum install -y epel-release && yum install -y make python36 && yum clean all
RUN yum install -y epel-release
RUN yum install -y python34
WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests
ENV LANG en_US.utf8
ENV SHELL /bin/bash
ENV USER docker
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

@@ -1,13 +1,33 @@
FROM debian:jessie
# syntax=docker/dockerfile:1.3
FROM docker.io/library/debian:bullseye
ENV DEBIAN_FRONTENT="noninteractive"
RUN apt-get update \
&& apt-get install -y \
ca-certificates \
locales \
make \
procps \
python3 \
&& localedef \
-i en_US \
-c \
-f UTF-8 \
-A /usr/share/locale/locale.alias \
en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
ca-certificates \
locales \
python3
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests
ENV LANG en_US.utf8
ENV SHELL /bin/bash
ENV USER docker
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
USER nobody
ENV SHELL="/bin/bash"
# vim:ft=dockerfile

View File

@@ -1,9 +1,20 @@
FROM fedora:26
# syntax=docker/dockerfile:1.3
# https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921
# https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html
FROM registry.fedoraproject.org/fedora:34
RUN dnf install -y make python3 procps && dnf clean all
RUN dnf install -y python3 procps
WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests
ENV LANG en_US.utf8
ENV SHELL /bin/bash
ENV USER docker
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
ENV SHELL="/bin/bash"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

@@ -1,13 +1,32 @@
FROM ubuntu:16.04
# syntax=docker/dockerfile:1.3
FROM docker.io/library/ubuntu:20.04
ENV DEBIAN_FRONTENT="noninteractive"
RUN apt-get update \
&& apt-get install -y \
ca-certificates \
locales \
make \
python3 \
&& localedef \
-i en_US \
-c \
-f UTF-8 \
-A /usr/share/locale/locale.alias \
en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
ca-certificates \
locales \
python3
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests
ENV LANG en_US.utf8
ENV SHELL /bin/bash
ENV USER docker
COPY asciinema/ asciinema/
COPY tests/ tests/
ENV LANG="en_US.utf8"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

@@ -1,21 +1,26 @@
#!/usr/bin/env bash
set -e
set -x
set -eExuo pipefail
if ! type "pkill" >/dev/null 2>&1; then
echo "error: pkill not installed"
if ! command -v "pkill" >/dev/null 2>&1; then
printf "error: pkill not installed\n"
exit 1
fi
python3 -V
export ASCIINEMA_CONFIG_HOME=`mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home`
TMP_DATA_DIR=`mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir`
trap "rm -rf $ASCIINEMA_CONFIG_HOME $TMP_DATA_DIR" EXIT
ASCIINEMA_CONFIG_HOME="$(
mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
)"
function asciinema() {
python3 -m asciinema "$@"
export ASCIINEMA_CONFIG_HOME
TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)"
trap 'rm -rf ${ASCIINEMA_CONFIG_HOME} ${TMP_DATA_DIR}' EXIT
asciinema() {
python3 -m asciinema "${@}"
}
## test help message
@@ -35,50 +40,56 @@ asciinema auth
# asciicast v1
asciinema play -s 5 tests/demo.json
asciinema play -s 5 -i 0.2 tests/demo.json
# shellcheck disable=SC2002
cat tests/demo.json | asciinema play -s 5 -
# asciicast v2
asciinema play -s 5 tests/demo.cast
asciinema play -s 5 -i 0.2 tests/demo.cast
# shellcheck disable=SC2002
cat tests/demo.cast | asciinema play -s 5 -
## test cat command
# asciicast v1
asciinema cat tests/demo.json
# shellcheck disable=SC2002
cat tests/demo.json | asciinema cat -
# asciicast v2
asciinema cat tests/demo.cast
# shellcheck disable=SC2002
cat tests/demo.cast | asciinema cat -
## test rec command
# normal program
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/1a.cast"
grep '"o",' "$TMP_DATA_DIR/1a.cast"
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cast"
grep '"o",' "${TMP_DATA_DIR}/1a.cast"
# very quickly exiting program
# https://github.com/asciinema/asciinema/issues/246
# asciinema rec -c who "$TMP_DATA_DIR/1b.cast"
# grep '"o",' "$TMP_DATA_DIR/1b.cast"
asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast"
grep '"o",' "${TMP_DATA_DIR}/1b.cast"
# signal handling
bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" &
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/2.cast"
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast"
bash -c "sleep 1; pkill -n -f 'bash -c echo t3st'" &
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/3.cast"
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/3.cast"
bash -c "sleep 1; pkill -9 -n -f 'bash -c echo t3st'" &
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/4.cast"
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/4.cast"
# with stdin recording
asciinema rec --stdin -c 'bash -c "echo t3st; sleep 1; echo ok"' "$TMP_DATA_DIR/5.cast"
echo "ls" | asciinema rec --stdin -c 'bash -c "sleep 1"' "${TMP_DATA_DIR}/5.cast"
cat "${TMP_DATA_DIR}/5.cast"
grep '"i", "ls\\n"' "${TMP_DATA_DIR}/5.cast"
grep '"o",' "${TMP_DATA_DIR}/5.cast"
# raw output recording
asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "$TMP_DATA_DIR/6.raw"
asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/6.raw"
# appending to existing recording
asciinema rec -c 'echo allright!; sleep 0.1' "$TMP_DATA_DIR/7.cast"
asciinema rec --append -c uptime "$TMP_DATA_DIR/7.cast"
asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast"
asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast"

View File

@@ -1,41 +1,54 @@
import os
import pty
from typing import Any, List, Union
import asciinema.pty_
from nose.tools import assert_equal
from .test_helper import Test
import asciinema.pty
class Writer:
def __init__(self) -> None:
self.data: List[Union[float, str]] = []
class FakeStdout:
def __init__(self):
self.data = []
def write_stdout(self, ts, data):
def write_stdout(self, _ts: float, data: Any) -> None:
self.data.append(data)
def write_stdin(self, ts, data):
pass
def write_stdin(self, ts: float, data: Any) -> None:
raise NotImplementedError
class TestRecord(Test):
def setUp(self):
def setUp(self) -> None:
self.real_os_write = os.write
os.write = self.os_write
os.write = self.os_write # type: ignore
def tearDown(self):
def tearDown(self) -> None:
os.write = self.real_os_write
def os_write(self, fd, data):
def os_write(self, fd: int, data: Any) -> None:
if fd != pty.STDOUT_FILENO:
self.real_os_write(fd, data)
def test_record_command_writes_to_stdout(self):
output = FakeStdout()
@staticmethod
def test_record_command_writes_to_stdout() -> None:
writer = Writer()
command = ['python3', '-c', "import sys; import time; sys.stdout.write(\'foo\'); sys.stdout.flush(); time.sleep(0.01); sys.stdout.write(\'bar\')"]
asciinema.pty.record(command, output)
command = [
"python3",
"-c",
(
"import sys"
"; import time"
"; sys.stdout.write('foo')"
"; sys.stdout.flush()"
"; time.sleep(0.01)"
"; sys.stdout.write('bar')"
),
]
assert_equal([b'foo', b'bar'], output.data)
asciinema.pty_.record(
command, {}, writer, lambda: (80, 24), lambda s: None, {}
)
assert writer.data == [b"foo", b"bar"]

View File

@@ -1,51 +1,16 @@
import sys
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
from codecs import StreamReader
from io import StringIO
from typing import Optional, TextIO, Union
stdout = None
def assert_printed(expected):
success = expected in stdout.getvalue()
assert success, 'expected text "%s" not printed' % expected
def assert_not_printed(expected):
success = expected not in stdout.getvalue()
assert success, 'not expected text "%s" printed' % expected
stdout: Optional[Union[TextIO, StreamReader]] = None
class Test:
def setUp(self):
global stdout
def setUp(self) -> None:
global stdout # pylint: disable=global-statement
self.real_stdout = sys.stdout
sys.stdout = stdout = StringIO()
def tearDown(self):
def tearDown(self) -> None:
sys.stdout = self.real_stdout
class FakeClock:
def __init__(self, values):
self.values = values
self.n = 0
def time(self):
value = self.values[self.n]
self.n += 1
return value
class FakeAsciicast:
def __init__(self, cmd=None, title=None, stdout=None, meta_data=None):
self.cmd = cmd
self.title = title
self.stdout = stdout
self.meta_data = meta_data or {}