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 name: build
on:
on: [push, pull_request] - push
- pull_request
jobs: jobs:
# Code style checks # Code style checks
health: health:
name: Code health check name: code health check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Asciinema - name: checkout asciinema
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Setup Python - name: setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v3
with: with:
python-version: 3.9 python-version: "3.10"
- name: Install dependencies - name: install dependencies
run: pip install pycodestyle twine setuptools>=38.6.0 cmarkgfm run: pip install build cmarkgfm pycodestyle twine
- name: Run pycodestyle - 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 - name: Run twine
run: | run: |
python setup.py --quiet sdist python3 -m build
twine check dist/* twine check dist/*
# Asciinema checks # Asciinema checks
asciinema: asciinema:
name: Asciinema - py${{ matrix.python }} name: Asciinema
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python: [3.6, 3.7, 3.8, 3.9] python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
- "3.10"
env: env:
TERM: dumb TERM: dumb
steps: steps:
- name: Checkout Asciinema - name: checkout Asciinema
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Setup Python - name: setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v3
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Install dependencies - name: install dependencies
run: pip install nose run: pip install pytest
- name: Run Asciinema tests - name: run Asciinema tests
run: script -e -c make test 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 # 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) ## 2.1.0 (2021-10-02)
* Ability to pause/resume terminal capture with `C-\` key shortcut * Ability to pause/resume terminal capture with `C-\` key shortcut
@@ -12,7 +26,6 @@
* Upload for users with very long `$USER` is fixed * Upload for users with very long `$USER` is fixed
* Added official support for Python 3.8 and 3.9 * Added official support for Python 3.8 and 3.9
* Dropped official support for EOL-ed Python 3.4 and 3.5 * Dropped official support for EOL-ed Python 3.4 and 3.5
* Internal refactorings
## 2.0.2 (2019-01-12) ## 2.0.2 (2019-01-12)

View File

@@ -1,49 +1,64 @@
# Contributing to asciinema # 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](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-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-player](https://github.com/asciinema/asciinema-player/issues) - player
## Reporting bugs ## 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). 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 ## Submitting patches with bug fixes
If you found a bug and made a patch for it: If you found a bug and made a patch for it:
* make sure all tests pass 1. Make sure your changes pass the [pre-commit](https://pre-commit.com/)
* send us a pull request, including a description of the fix (referencing an existing issue if there's one) [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 ## Requesting new features
We welcome all ideas. 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) ## 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 ## 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 If you need help then either join #asciinema IRC channel on Libera.Chat or
us an email at support@asciinema.org. drop us an email at <support@asciinema.org>.
## Reporting security issues ## Reporting security issues
If you found a security issue in asciinema please contact us at support@asciinema.org. If you found a security issue in asciinema please contact us at
For the benefit of all asciinema users please **do not** publish details of the vulnerability in a Github issue. 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----- -----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v2 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 \ FROM docker.io/library/ubuntu:20.04
ca-certificates \
locales \ ENV DEBIAN_FRONTEND="noninteractive"
python3 \
python3-setuptools RUN apt-get update \
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 && apt-get install -y \
RUN mkdir /usr/src/app ca-certificates \
COPY setup.cfg /usr/src/app locales \
COPY setup.py /usr/src/app python3 \
COPY *.md /usr/src/app/ 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 doc/*.md /usr/src/app/doc/
COPY man/asciinema.1 /usr/src/app/man/ 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 WORKDIR /usr/src/app
RUN python3 setup.py install
ENV LANG en_US.utf8 RUN pip3 install .
ENV SHELL /bin/bash
ENV USER docker
WORKDIR /root WORKDIR /root
ENV LANG="en_US.utf8"
ENV SHELL="/bin/bash"
ENTRYPOINT ["/usr/local/bin/asciinema"] ENTRYPOINT ["/usr/local/bin/asciinema"]
CMD ["--help"]
# vim:ft=dockerfile

View File

@@ -1,31 +1,75 @@
NAME=asciinema NAME := asciinema
VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"` VERSION := $(shell python3 -c "import asciinema; print(asciinema.__version__)")
test: test-unit test-integration VIRTUAL_ENV ?= .venv
test-unit: .PHONY: test
nosetests test: test.unit test.integration
test-integration: .PHONY: test.unit
test.unit:
pytest
.PHONY: test.integration
test.integration:
tests/integration.sh tests/integration.sh
.PHONY: test.distros
test.distros:
tests/distros.sh
.PHONY: release
release: test tag push release: test tag push
release-test: test push-test .PHONY: release.test
release.test: test push.test
tag: .PHONY: .tag.exists
git tag | grep "v$(VERSION)" && echo "Tag v$(VERSION) exists" && exit 1 || true .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 tag -s -m "Releasing $(VERSION)" v$(VERSION)
git push origin v$(VERSION) git push origin v$(VERSION)
push: .PHONY: .venv
python3 -m pip install --user --upgrade --quiet twine .venv:
python3 setup.py sdist bdist_wheel 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/* python3 -m twine upload dist/*
push-test: .PHONY: push.test
python3 -m pip install --user --upgrade --quiet twine push.test: .pip build
python3 setup.py sdist bdist_wheel
python3 -m twine upload --repository testpypi dist/* 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 asciinema lets you easily record terminal sessions and replay
them in a terminal as well as in a web browser. 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: Record your first session:
asciinema rec first.cast ```sh
asciinema rec first.cast
```
Now replay it with double speed: 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: 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 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 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: 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 The above uploads it to [asciinema.org](https://asciinema.org), which is a
default [asciinema-server](https://github.com/asciinema/asciinema-server) 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: 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 You'll be asked to confirm the upload when the recording is done. Nothing is
sent anywhere without your consent. sent anywhere without your consent.
@@ -55,15 +74,25 @@ cover installation, usage and hosting of the recordings in more detail.
## Installation ## Installation
### Python package ### Python package from PyPI
asciinema is available on [PyPI](https://pypi.python.org/pypi/asciinema) and can [pypi]: https://pypi.python.org/pypi/asciinema
be installed with pip (Python 3 with setuptools required):
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 ```sh
version. 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 ### Native packages
@@ -78,32 +107,45 @@ can clone the repo and run asciinema straight from the checkout.
Clone the repo: Clone the repo:
git clone https://github.com/asciinema/asciinema.git ```sh
cd asciinema git clone https://github.com/asciinema/asciinema.git
cd asciinema
```
If you want latest stable version: If you want latest stable version:
git checkout master ```sh
git checkout master
```
If you want current development version: If you want current development version:
git checkout develop ```sh
git checkout develop
```
Then run it with: Then run it with:
python3 -m asciinema --version ```sh
python3 -m asciinema --version
```
### Docker image ### 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. 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 When running it don't forget to allocate a pseudo-TTY (`-t`), keep STDIN open
(`-i`) and mount config directory volume (`-v`): (`-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'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 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 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 want to install extra programs before recording. One option is to derive new
image from this one (start your custom Dockerfile with `FROM image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash`
asciinema/asciinema`). Another option is to start the container with `/bin/bash`
as the entrypoint, install extra packages and manually start `asciinema rec`: as the entrypoint, install extra packages and manually start `asciinema rec`:
docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema --entrypoint=/bin/bash asciinema/asciinema ```console
root@6689517d99a1:~# apt-get install foobar docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash docker.io/asciinema/asciinema rec
root@6689517d99a1:~# 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 ## Usage
@@ -129,7 +186,7 @@ all available commands with their options.
### `rec [filename]` ### `rec [filename]`
__Record terminal session.__ **Record terminal session.**
By running `asciinema rec [filename]` you start a new recording session. The By running `asciinema rec [filename]` you start a new recording session. The
command (process) that is recorded can be specified with `-c` option (see 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: Available options:
* `--stdin` - Enable stdin (keyboard) recording (see below) - `--stdin` - Enable stdin (keyboard) recording (see below)
* `--append` - Append to existing recording - `--append` - Append to existing recording
* `--raw` - Save raw STDOUT output, without timing information or other metadata - `--raw` - Save raw STDOUT output, without timing information or other metadata
* `--overwrite` - Overwrite the recording if it already exists - `--overwrite` - Overwrite the recording if it already exists
* `-c, --command=<command>` - Specify command to record, defaults to $SHELL - `-c, --command=<command>` - Specify command to record, defaults to $SHELL
* `-e, --env=<var-names>` - List of environment variables to capture, defaults - `-e, --env=<var-names>` - List of environment variables to capture, defaults
to `SHELL,TERM` to `SHELL,TERM`
* `-t, --title=<title>` - Specify the title of the asciicast - `-t, --title=<title>` - Specify the title of the asciicast
* `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds - `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
* `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation) - `--cols=<n>` - Override terminal columns for recorded process
* `-q, --quiet` - Be quiet, suppress all notices/warnings (implies -y) - `--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 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. 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>` ### `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 This command replays given asciicast (as recorded by `rec` command) directly in
your terminal. your terminal.
@@ -199,32 +258,41 @@ keyboard shortcuts.
Playing from a local file: Playing from a local file:
asciinema play /path/to/asciicast.cast ```sh
asciinema play /path/to/asciicast.cast
```
Playing from HTTP(S) URL: Playing from HTTP(S) URL:
asciinema play https://asciinema.org/a/22124.cast ```sh
asciinema play http://example.com/demo.cast asciinema play https://asciinema.org/a/22124.cast
asciinema play http://example.com/demo.cast
```
Playing from asciicast page URL (requires `<link rel="alternate" Playing from asciicast page URL (requires `<link rel="alternate" type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
asciinema play https://asciinema.org/a/22124 ```sh
asciinema play http://example.com/blog/post.html asciinema play https://asciinema.org/a/22124
asciinema play http://example.com/blog/post.html
```
Playing from stdin: Playing from stdin:
cat /path/to/asciicast.cast | asciinema play - ```sh
ssh user@host cat asciicast.cast | asciinema play - cat /path/to/asciicast.cast | asciinema play -
ssh user@host cat asciicast.cast | asciinema play -
```
Playing from IPFS: Playing from IPFS:
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast ```sh
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
```
Available options: Available options:
* `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds - `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
* `-s, --speed=<factor>` - Playback speed (can be fractional) - `-s, --speed=<factor>` - Playback speed (can be fractional)
> For the best playback experience it is recommended to run `asciinema play` in > 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 > a terminal of dimensions not smaller than the one used for recording, as
@@ -232,7 +300,7 @@ Available options:
### `cat <filename>` ### `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 While `asciinema play <filename>` replays the recorded session using timing
information saved in the asciicast, `asciinema cat <filename>` dumps the full 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 <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 This command uploads given asciicast (recorded by `rec` command) to
asciinema.org, where it can be watched and shared. asciinema.org, where it can be watched and shared.
`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload `asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload demo.cast` is a nice combo if you want to review an asciicast before
demo.cast` is a nice combo if you want to review an asciicast before
publishing it on asciinema.org. publishing it on asciinema.org.
### `auth` ### `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 If you want to manage your recordings (change title/theme, delete) at
asciinema.org you need to link your "install ID" with asciinema.org user asciinema.org you need to link your "install ID" with asciinema.org user
@@ -402,4 +469,5 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
Copyright &copy; 20112021 Marcin Kulik. 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 import sys
__author__ = 'Marcin Kulik' __author__ = "Marcin Kulik"
__version__ = '2.1.0' __version__ = "2.2.0"
if sys.version_info[0] < 3: if sys.version_info < (3, 6):
raise ImportError('Python < 3 is unsupported.') 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, def record_asciicast( # pylint: disable=too-many-arguments
rec_stdin=False, title=None, metadata=None, path_: str,
command_env=None, capture_env=None): command: Any = None,
asciinema.recorder.record( append: bool = False,
path, 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, command=command,
append=append, append=append,
idle_time_limit=idle_time_limit, idle_time_limit=idle_time_limit,
rec_stdin=rec_stdin, record_stdin=record_stdin,
title=title, title=title,
metadata=metadata,
command_env=command_env, command_env=command_env,
capture_env=capture_env capture_env=capture_env,
) )

View File

@@ -1,40 +1,56 @@
import locale
import argparse import argparse
import locale
import os import os
import sys import sys
from typing import Any, Optional
from asciinema import __version__ from . import __version__, config
import asciinema.config as config from .commands.auth import AuthCommand
from asciinema.commands.auth import AuthCommand from .commands.cat import CatCommand
from asciinema.commands.record import RecordCommand from .commands.play import PlayCommand
from asciinema.commands.play import PlayCommand from .commands.record import RecordCommand
from asciinema.commands.cat import CatCommand from .commands.upload import UploadCommand
from asciinema.commands.upload import UploadCommand
def positive_float(value): def positive_int(value: str) -> int:
value = float(value) _value = int(value)
if value <= 0.0: if _value <= 0:
raise argparse.ArgumentTypeError("must be positive") 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: if v is not None:
return str(v) return str(v)
return None
def main(): def main() -> Any:
if locale.nl_langinfo(locale.CODESET).upper() not in ['US-ASCII', 'UTF-8', 'UTF8']: if locale.nl_langinfo(locale.CODESET).upper() not in [
print("asciinema needs an ASCII or UTF-8 character encoding to run. Check the output of `locale` command.") "US-ASCII",
sys.exit(1) "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: try:
cfg = config.load() cfg = config.load()
except config.ConfigError as e: except config.ConfigError as e:
sys.stderr.write(str(e) + '\n') sys.stderr.write(f"{e}\n")
sys.exit(1) return 1
# create the top-level parser # create the top-level parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -56,60 +72,152 @@ def main():
\x1b[1masciinema cat demo.cast\x1b[0m \x1b[1masciinema cat demo.cast\x1b[0m
For help on a specific command run: For help on a specific command run:
\x1b[1masciinema <command> -h\x1b[0m""", \x1b[1masciinema <command> -h\x1b[0m""", # noqa: E501
formatter_class=argparse.RawDescriptionHelpFormatter 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() subparsers = parser.add_subparsers()
# create the parser for the "rec" command # create the parser for the `rec` command
parser_rec = subparsers.add_parser('rec', help='Record terminal session') 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(
parser_rec.add_argument('--append', help='append to existing recording', action='store_true', default=False) "--stdin",
parser_rec.add_argument('--raw', help='save only raw stdout output', action='store_true', default=False) help="enable stdin recording, disabled by default",
parser_rec.add_argument('--overwrite', help='overwrite the file if it already exists', action='store_true', default=False) action="store_true",
parser_rec.add_argument('-c', '--command', help='command to record, defaults to $SHELL', default=cfg.record_command) default=cfg.record_stdin,
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(
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)) "--append",
parser_rec.add_argument('-y', '--yes', help='answer "yes" to all prompts (e.g. upload confirmation)', action='store_true', default=cfg.record_yes) help="append to existing recording",
parser_rec.add_argument('-q', '--quiet', help='be quiet, suppress all notices/warnings (implies -y)', action='store_true', default=cfg.record_quiet) action="store_true",
parser_rec.add_argument('filename', nargs='?', default='', help='filename/path to save the recording to') 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) parser_rec.set_defaults(cmd=RecordCommand)
# create the parser for the "play" command # create the parser for the `play` command
parser_play = subparsers.add_parser('play', help='Replay terminal session') 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(
parser_play.add_argument('-s', '--speed', help='playback speedup (can be fractional)', type=positive_float, default=cfg.play_speed) "-i",
parser_play.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)') "--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) parser_play.set_defaults(cmd=PlayCommand)
# create the parser for the "cat" command # create the parser for the `cat` command
parser_cat = subparsers.add_parser('cat', help='Print full output of terminal session') parser_cat = subparsers.add_parser(
parser_cat.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)') "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) parser_cat.set_defaults(cmd=CatCommand)
# create the parser for the "upload" command # create the parser for the `upload` command
parser_upload = subparsers.add_parser('upload', help='Upload locally saved terminal session to asciinema.org') parser_upload = subparsers.add_parser(
parser_upload.add_argument('filename', help='filename or path of local recording') "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) parser_upload.set_defaults(cmd=UploadCommand)
# create the parser for the "auth" command # create the parser for the `auth` command
parser_auth = subparsers.add_parser('auth', help='Manage recordings on asciinema.org account') parser_auth = subparsers.add_parser(
"auth", help="Manage recordings on asciinema.org account"
)
parser_auth.set_defaults(cmd=AuthCommand) parser_auth.set_defaults(cmd=AuthCommand)
# parse the args and call whatever function was selected # parse the args and call whatever function was selected
args = parser.parse_args() args = parser.parse_args()
if hasattr(args, 'cmd'): if hasattr(args, "cmd"):
command = args.cmd(args, cfg, os.environ) command = args.cmd(args, cfg, os.environ)
code = command.execute() code = command.execute()
sys.exit(code) return code
else:
parser.print_help() parser.print_help()
sys.exit(1) return 1
if __name__ == '__main__': if __name__ == "__main__":
main() sys.exit(main())

View File

@@ -1,11 +1,12 @@
import json
import platform import platform
import re import re
import json from typing import Any, Callable, Dict, Optional, Tuple, Union
from urllib.parse import urlparse from urllib.parse import urlparse
from asciinema import __version__ from . import __version__
from asciinema.urllib_http_adapter import URLLibHttpAdapter from .http_adapter import HTTPConnectionError
from asciinema.http_adapter import HTTPConnectionError from .urllib_http_adapter import URLLibHttpAdapter
class APIError(Exception): class APIError(Exception):
@@ -13,73 +14,88 @@ class APIError(Exception):
class Api: class Api:
def __init__(
def __init__(self, url, user, install_id, http_adapter=None): self,
url: str,
user: Optional[str],
install_id: str,
http_adapter: Any = None,
) -> None:
self.url = url self.url = url
self.user = user self.user = user
self.install_id = install_id 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 return urlparse(self.url).hostname
def auth_url(self): def auth_url(self) -> str:
return "{}/connect/{}".format(self.url, self.install_id) return f"{self.url}/connect/{self.install_id}"
def upload_url(self): def upload_url(self) -> str:
return "{}/api/asciicasts".format(self.url) return f"{self.url}/api/asciicasts"
def upload_asciicast(self, path): def upload_asciicast(self, path_: str) -> Tuple[Any, Any]:
with open(path, 'rb') as f: with open(path_, "rb") as f:
try: try:
status, headers, body = self.http_adapter.post( status, headers, body = self.http_adapter.post(
self.upload_url(), self.upload_url(),
files={"asciicast": ("ascii.cast", f)}, files={"asciicast": ("ascii.cast", f)},
headers=self._headers(), headers=self._headers(),
username=self.user, username=self.user,
password=self.install_id password=self.install_id,
) )
except HTTPConnectionError as e: 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) 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) result = json.loads(body)
else: else:
result = {'url': body} result = {"url": body}
return result, headers.get('Warning') return result, headers.get("Warning")
def _headers(self): def _headers(self) -> Dict[str, Union[Callable[[], str], str]]:
return {'User-Agent': self._user_agent(), 'Accept': 'application/json'} return {"user-agent": self._user_agent(), "accept": "application/json"}
def _user_agent(self): @staticmethod
os = re.sub('([^-]+)-(.*)', '\\1/\\2', platform.platform()) def _user_agent() -> str:
os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform())
return 'asciinema/%s %s/%s %s' % (__version__, return (
platform.python_implementation(), f"asciinema/{__version__} {platform.python_implementation()}"
platform.python_version(), f"/{platform.python_version()} {os}"
os )
)
def _handle_error(self, status, body): @staticmethod
def _handle_error(status: int, body: bytes) -> None:
errors = { errors = {
400: "Invalid request: %s" % body, 400: f"Invalid request: {body.decode('utf-8', 'replace')}",
401: "Invalid or revoked install ID", 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.", 413: "Sorry, your asciicast is too big.",
422: "Invalid asciicast: %s" % body, 422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
503: "The server is down for maintenance. Try again in a minute." 503: "The server is down for maintenance. Try again in a minute.",
} }
error = errors.get(status) error = errors.get(status)
if not error: if not error:
if status >= 500: 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: else:
error = "HTTP status: %i" % status error = f"HTTP status: {status}"
raise APIError(error) 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 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 v1, v2
from . import v2
class LoadError(Exception): class LoadError(Exception):
pass pass
class Parser(html.parser.HTMLParser): class Parser(HTMLParser):
def __init__(self): def __init__(self) -> None:
html.parser.HTMLParser.__init__(self) HTMLParser.__init__(self)
self.url = None self.url = None
def handle_starttag(self, tag, attrs_list): def error(self, message: str) -> None:
# look for <link rel="alternate" type="application/x-asciicast" href="https://...cast"> raise NotImplementedError(
if tag == 'link': "subclasses of ParserBase must override error()"
attrs = {} ", but HTMLParser does not"
for k, v in attrs_list: )
attrs[k] = v
if attrs.get('rel') == 'alternate': def handle_starttag(self, tag: str, attrs: List[Any]) -> None:
type = attrs.get('type') # look for <link rel="alternate"
if type == 'application/asciicast+json' or type == 'application/x-asciicast': # type="application/x-asciicast"
self.url = attrs.get('href') # 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 == "-": if url == "-":
return sys.stdin return sys.stdin
if url.startswith("ipfs://"): 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/"): 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:"): if url.startswith("http:") or url.startswith("https:"):
req = Request(url) req = Request(url)
req.add_header('Accept-Encoding', 'gzip') req.add_header("Accept-Encoding", "gzip")
response = urlopen(req) with urlopen(req) as response:
body = response body = response
url = response.geturl() # final URL after redirects url = response.geturl() # final URL after redirects
if response.headers['Content-Encoding'] == 'gzip': if response.headers["Content-Encoding"] == "gzip":
body = gzip.open(body) body = gzip.open(body)
utf8_reader = codecs.getreader('utf-8') utf8_reader = codecs.getreader("utf-8")
content_type = response.headers['Content-Type'] content_type = response.headers["Content-Type"]
if content_type and content_type.startswith('text/html'): if content_type and content_type.startswith("text/html"):
html = utf8_reader(body, errors='replace').read() html = utf8_reader(body, errors="replace").read()
parser = Parser() parser = Parser()
parser.feed(html) parser.feed(html)
new_url = parser.url new_url = parser.url
if not new_url: if not new_url:
raise LoadError("""<link rel="alternate" type="application/x-asciicast" href="..."> not found in fetched HTML document""") raise LoadError(
'<link rel="alternate" '
'type="application/x-asciicast" '
'href="..."> '
"not found in fetched HTML document"
)
if "://" not in new_url: if "://" not in new_url:
base_url = urlparse(url) base_url = urlparse(url)
if new_url.startswith("/"): if new_url.startswith("/"):
new_url = urlunparse((base_url[0], base_url[1], new_url, '', '', '')) new_url = urlunparse(
else: (base_url[0], base_url[1], new_url, "", "", "")
path = os.path.dirname(base_url[2]) + '/' + new_url )
new_url = urlunparse((base_url[0], base_url[1], path, '', '', '')) 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" 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.url = url
self.file: Union[StreamReader, TextIO, None] = None
self.context: Any = None
def __enter__(self): def __enter__(self) -> Any:
try: try:
self.file = open_url(self.url) self.file = open_url(self.url)
first_line = self.file.readline() first_line = self.file.readline()
@@ -98,11 +122,13 @@ class open_from_url():
try: # try v1 next try: # try v1 next
self.context = v1.open_from_file(first_line, self.file) self.context = v1.open_from_file(first_line, self.file)
return self.context.__enter__() return self.context.__enter__()
except v1.LoadError: except v1.LoadError as e:
raise LoadError(self.FORMAT_ERROR) raise LoadError(self.FORMAT_ERROR) from e
except (OSError, urllib.error.HTTPError) as 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) 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 prev_time = 0
for frame in events: for frame in events:
time, type, data = frame time, type_, data = frame
delay = time - prev_time delay = time - prev_time
prev_time = 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 time = 0
for frame in events: for frame in events:
delay, type, data = frame delay, type_, data = frame
time = time + delay 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: if time_limit:
return ([min(delay, time_limit), type, data] for delay, type, data in events) return (
else: [min(delay, time_limit), type_, data]
return events for delay, type_, data in events
)
return events
def adjust_speed(events, speed): def adjust_speed(
return ([delay / speed, type, data] for delay, type, data in events) 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 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 (
if append and os.path.exists(path) and os.stat(path).st_size == 0: # true for pipes append and path.exists(path_) and os.stat(path_).st_size == 0
): # true for pipes
append = False append = False
self.path = path
self.buffering = buffering self.buffering = buffering
self.mode = 'ab' if append else 'wb' self.mode: str = "ab" if append else "wb"
self.metadata = metadata
def __enter__(self): def write_stdout(self, _ts: float, data: Any) -> None:
self.file = open(self.path, mode=self.mode, buffering=self.buffering) self._write(data)
return self
def __exit__(self, exc_type, exc_value, exc_traceback): # pylint: disable=no-self-use
self.file.close() def write_stdin(self, ts: float, data: Any) -> None:
def write_stdout(self, ts, data):
self.file.write(data)
def write_stdin(self, ts, data):
pass 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
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 from .events import to_absolute_time
try:
JSONDecodeError = json.decoder.JSONDecodeError
except AttributeError:
JSONDecodeError = ValueError
class LoadError(Exception): class LoadError(Exception):
@@ -15,46 +11,52 @@ class LoadError(Exception):
class Asciicast: class Asciicast:
def __init__(self, attrs: Dict[str, Any]) -> None:
def __init__(self, attrs): self.version: int = 1
self.version = 1
self.__attrs = attrs self.__attrs = attrs
self.idle_time_limit = None # v1 doesn't store it self.idle_time_limit = None # v1 doesn't store it
@property @property
def v2_header(self): def v2_header(self) -> Dict[str, Any]:
keys = ['width', 'height', 'duration', 'command', 'title', 'env'] 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} header = {
k: v
for k, v in self.__attrs.items()
if k in keys and v is not None
}
return header return header
def __stdout_events(self): def __stdout_events(self) -> Generator[List[Any], None, None]:
for time, data in self.__attrs['stdout']: for time, data in self.__attrs["stdout"]:
yield [time, 'o', data] yield [time, "o", data]
def events(self): def events(self) -> Any:
return self.stdout_events() return self.stdout_events()
def stdout_events(self): def stdout_events(self) -> Generator[List[Any], None, None]:
return to_absolute_time(self.__stdout_events()) return to_absolute_time(self.__stdout_events())
class open_from_file(): class open_from_file:
FORMAT_ERROR = "only asciicast v1 format can be opened" 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.first_line = first_line
self.file = file self.file = file
def __enter__(self): def __enter__(self) -> Optional[Asciicast]:
try: try:
attrs = json.loads(self.first_line + self.file.read()) attrs = json.loads(self.first_line + self.file.read())
if attrs.get('version') == 1: if attrs.get("version") == 1:
return Asciicast(attrs) return Asciicast(attrs)
else:
raise LoadError(self.FORMAT_ERROR)
except JSONDecodeError as e:
raise LoadError(self.FORMAT_ERROR) 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() self.file.close()

View File

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

View File

@@ -1,31 +1,46 @@
from typing import Any, Optional
try: try:
# Importing synchronize is to detect platforms where # Importing synchronize is to detect platforms where
# multiprocessing does not work (python issue 3770) # multiprocessing does not work (python issue 3770)
# and cause an ImportError. Otherwise it will happen # and cause an ImportError. Otherwise it will happen
# later when trying to use Queue(). # 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: except ImportError:
from threading import Thread as Process from queue import Queue # type: ignore
from queue import Queue 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): def __enter__(self) -> Any:
self.queue = Queue()
def __enter__(self):
self.process = Process(target=self.run) self.process = Process(target=self.run)
self.process.start() self.process.start()
return self 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) self.queue.put(None)
assert isinstance(self.process, Process)
self.process.join() 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) self.queue.put(payload)
def run(self): def run(self) -> None:
payload: Any
for payload in iter(self.queue.get, None): 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): class AuthCommand(Command):
def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
def __init__(self, args, config, env):
Command.__init__(self, args, config, env) Command.__init__(self, args, config, env)
def execute(self): def execute(self) -> None:
self.print('Open the following URL in a web browser to link your ' self.print(
'install ID with your %s user account:\n\n' f"Open the following URL in a web browser to link your install ID "
'%s\n\n' f"with your {self.api.hostname()} user account:\n\n"
'This will associate all recordings uploaded from this machine ' f"{self.api.auth_url()}\n\n"
'(past and future ones) to your account, ' "This will associate all recordings uploaded from this machine "
'and allow you to manage them (change title/theme, delete) at %s.' "(past and future ones) to your account"
% (self.api.hostname(), self.api.auth_url(), self.api.hostname())) ", and allow you to manage them (change title/theme, delete) at "
f"{self.api.hostname()}."
)

View File

@@ -1,27 +1,28 @@
import sys import sys
from typing import Any, Dict
from asciinema.commands.command import Command from .. import asciicast
from asciinema.term import raw from ..config import Config
import asciinema.asciicast as asciicast from ..tty_ import raw
from .command import Command
class CatCommand(Command): class CatCommand(Command):
def __init__(self, args: Any, config: Config, env: Dict[str, str]):
def __init__(self, args, config, env):
Command.__init__(self, args, config, env) Command.__init__(self, args, config, env)
self.filename = args.filename self.filename = args.filename
def execute(self): def execute(self) -> int:
try: try:
stdin = open('/dev/tty') with open("/dev/tty", "rt", encoding="utf-8") as stdin:
with raw(stdin.fileno()): with raw(stdin.fileno()):
with asciicast.open_from_url(self.filename) as a: with asciicast.open_from_url(self.filename) as a:
for t, _type, text in a.stdout_events(): for _, _type, text in a.stdout_events():
sys.stdout.write(text) sys.stdout.write(text)
sys.stdout.flush() sys.stdout.flush()
except asciicast.LoadError as e: except asciicast.LoadError as e:
self.print_error("printing failed: %s" % str(e)) self.print_error(f"printing failed: {str(e)}")
return 1 return 1
return 0 return 0

View File

@@ -1,23 +1,34 @@
import os
import sys import sys
from typing import Any, Dict, Optional
from asciinema.api import Api from ..api import Api
from ..config import Config
class Command: class Command:
def __init__(self, _args: Any, config: Config, env: Dict[str, str]):
def __init__(self, args, config, env): self.quiet: bool = False
self.quiet = False
self.api = Api(config.api_url, env.get("USER"), config.install_id) 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: 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): print(text, file=sys.stderr, end=end)
self.print("\x1b[0;32masciinema: %s\x1b[0m" % text)
def print_warning(self, text): def print_info(self, text: str) -> None:
self.print("\x1b[0;33masciinema: %s\x1b[0m" % text) self.print(f"asciinema: {text}", color=2)
def print_error(self, text): def print_warning(self, text: str) -> None:
self.print("\x1b[0;31masciinema: %s\x1b[0m" % text, file=sys.stderr, force=True) 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 typing import Any, Dict, Optional
from asciinema.player import Player
import asciinema.asciicast as asciicast from .. import asciicast
from ..commands.command import Command
from ..config import Config
from ..player import Player
class PlayCommand(Command): class PlayCommand(Command):
def __init__(
def __init__(self, args, config, env, player=None): self,
args: Any,
config: Config,
env: Dict[str, str],
player: Optional[Player] = None,
) -> None:
Command.__init__(self, args, config, env) Command.__init__(self, args, config, env)
self.filename = args.filename self.filename = args.filename
self.idle_time_limit = args.idle_time_limit self.idle_time_limit = args.idle_time_limit
self.speed = args.speed self.speed = args.speed
self.player = player if player is not None else Player() self.player = player if player is not None else Player()
self.key_bindings = { self.key_bindings = {
'pause': config.play_pause_key, "pause": config.play_pause_key,
'step': config.play_step_key "step": config.play_step_key,
} }
def execute(self): def execute(self) -> int:
try: try:
with asciicast.open_from_url(self.filename) as a: 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: except asciicast.LoadError as e:
self.print_error("playback failed: %s" % str(e)) self.print_error(f"playback failed: {str(e)}")
return 1 return 1
except KeyboardInterrupt: except KeyboardInterrupt:
return 1 return 1

View File

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

View File

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

View File

@@ -1,168 +1,198 @@
import os
import os.path as path
import sys
import uuid
import configparser 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): class ConfigError(Exception):
pass pass
DEFAULT_API_URL = 'https://asciinema.org'
DEFAULT_RECORD_ENV = 'SHELL,TERM'
class Config: class Config:
def __init__(
def __init__(self, config_home, env=None): self,
config_home: Any,
env: Optional[Dict[str, str]] = None,
) -> None:
self.config_home = config_home self.config_home = config_home
self.config_file_path = path.join(config_home, "config") 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 = configparser.ConfigParser()
self.config.read(self.config_file_path) self.config.read(self.config_file_path)
self.env = env if env is not None else os.environ self.env = env if env is not None else os.environ
def upgrade(self): def upgrade(self) -> None:
try: try:
self.install_id self.install_id
except ConfigError: except ConfigError:
id = self.__api_token() or self.__user_token() or self.__gen_install_id() id_ = (
self.__save_install_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()} items = {
if items == {'DEFAULT': {}, 'api': {'token': id}} or items == {'DEFAULT': {}, 'user': {'token': id}}: 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) os.remove(self.config_file_path)
if self.env.get('ASCIINEMA_API_TOKEN'): if self.env.get("ASCIINEMA_API_TOKEN"):
raise ConfigError('ASCIINEMA_API_TOKEN variable is no longer supported, please use ASCIINEMA_INSTALL_ID instead') 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 p = self.install_id_path
if path.isfile(p): if path.isfile(p):
with open(p, 'r') as f: with open(p, "r", encoding="utf-8") as f:
return f.read().strip() return f.read().strip()
return None
def __gen_install_id(self): @staticmethod
return str(uuid.uuid4()) 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() self.__create_config_home()
with open(self.install_id_path, 'w') as f: with open(self.install_id_path, "w", encoding="utf-8") as f:
f.write(id) f.write(id_)
def __create_config_home(self): def __create_config_home(self) -> None:
if not path.exists(self.config_home): if not path.exists(self.config_home):
os.makedirs(self.config_home) os.makedirs(self.config_home)
def __api_token(self): def __api_token(self) -> Optional[str]:
try: try:
return self.config.get('api', 'token') return self.config.get("api", "token")
except (configparser.NoOptionError, configparser.NoSectionError): except (configparser.NoOptionError, configparser.NoSectionError):
pass return None
def __user_token(self): def __user_token(self) -> Optional[str]:
try: try:
return self.config.get('user', 'token') return self.config.get("user", "token")
except (configparser.NoOptionError, configparser.NoSectionError): except (configparser.NoOptionError, configparser.NoSectionError):
pass return None
@property @property
def install_id(self): def install_id(self) -> str:
id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id() id_ = self.env.get("ASCIINEMA_INSTALL_ID") or self.__read_install_id()
if id: if id_:
return id return id_
else: raise ConfigError("no install ID found")
raise ConfigError('no install ID found')
@property @property
def api_url(self): def api_url(self) -> str:
return self.env.get( return self.env.get(
'ASCIINEMA_API_URL', "ASCIINEMA_API_URL",
self.config.get('api', 'url', fallback=DEFAULT_API_URL) self.config.get("api", "url", fallback=DEFAULT_API_URL),
) )
@property @property
def record_stdin(self): def record_stdin(self) -> bool:
return self.config.getboolean('record', 'stdin', fallback=False) return self.config.getboolean("record", "stdin", fallback=False)
@property @property
def record_command(self): def record_command(self) -> Optional[str]:
return self.config.get('record', 'command', fallback=None) return self.config.get("record", "command", fallback=None)
@property @property
def record_env(self): def record_env(self) -> str:
return self.config.get('record', 'env', fallback=DEFAULT_RECORD_ENV) return self.config.get("record", "env", fallback=DEFAULT_RECORD_ENV)
@property @property
def record_idle_time_limit(self): def record_idle_time_limit(self) -> Optional[float]:
fallback = self.config.getfloat('record', 'maxwait', fallback=None) # pre 2.0 fallback = self.config.getfloat(
return self.config.getfloat('record', 'idle_time_limit', fallback=fallback) "record", "maxwait", fallback=None
) # pre 2.0
return self.config.getfloat(
"record", "idle_time_limit", fallback=fallback
)
@property @property
def record_yes(self): def record_yes(self) -> bool:
return self.config.getboolean('record', 'yes', fallback=False) return self.config.getboolean("record", "yes", fallback=False)
@property @property
def record_quiet(self): def record_quiet(self) -> bool:
return self.config.getboolean('record', 'quiet', fallback=False) return self.config.getboolean("record", "quiet", fallback=False)
@property @property
def record_prefix_key(self): def record_prefix_key(self) -> Any:
return self.__get_key('record', 'prefix') return self.__get_key("record", "prefix")
@property @property
def record_pause_key(self): def record_pause_key(self) -> Any:
return self.__get_key('record', 'pause', 'C-\\') return self.__get_key("record", "pause", "C-\\")
@property @property
def play_idle_time_limit(self): def play_idle_time_limit(self) -> Optional[float]:
fallback = self.config.getfloat('play', 'maxwait', fallback=None) # pre 2.0 fallback = self.config.getfloat(
return self.config.getfloat('play', 'idle_time_limit', fallback=fallback) "play", "maxwait", fallback=None
) # pre 2.0
return self.config.getfloat(
"play", "idle_time_limit", fallback=fallback
)
@property @property
def play_speed(self): def play_speed(self) -> float:
return self.config.getfloat('play', 'speed', fallback=1.0) return self.config.getfloat("play", "speed", fallback=1.0)
@property @property
def play_pause_key(self): def play_pause_key(self) -> Any:
return self.__get_key('play', 'pause', ' ') return self.__get_key("play", "pause", " ")
@property @property
def play_step_key(self): def play_step_key(self) -> Any:
return self.__get_key('play', 'step', '.') return self.__get_key("play", "step", ".")
@property @property
def notifications_enabled(self): def notifications_enabled(self) -> bool:
return self.config.getboolean('notifications', 'enabled', fallback=True) return self.config.getboolean(
"notifications", "enabled", fallback=True
)
@property @property
def notifications_command(self): def notifications_command(self) -> Optional[str]:
return self.config.get('notifications', 'command', fallback=None) return self.config.get("notifications", "command", fallback=None)
def __get_key(self, section, name, default=None): def __get_key(self, section: str, name: str, default: Any = None) -> Any:
key = self.config.get(section, name + '_key', fallback=default) key = self.config.get(section, f"{name}_key", fallback=default)
if key: if key:
if len(key) == 3: if len(key) == 3:
upper_key = key.upper() 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]) return bytes([ord(upper_key[2]) - 0x40])
else: raise ConfigError(
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)) f"invalid {name} key definition '{key}' - use"
else: f": {name}_key = C-x (with control key modifier)"
return key.encode('utf-8') 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_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
env_xdg_config_home = env.get("XDG_CONFIG_HOME") env_xdg_config_home = env.get("XDG_CONFIG_HOME")
env_home = env.get("HOME") env_home = env.get("HOME")
config_home = None config_home: Optional[str] = None
if env_asciinema_config_home: if env_asciinema_config_home:
config_home = env_asciinema_config_home config_home = env_asciinema_config_home
@@ -175,12 +205,16 @@ def get_config_home(env=os.environ):
else: else:
config_home = path.join(env_home, ".config", "asciinema") config_home = path.join(env_home, ".config", "asciinema")
else: 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 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 = Config(get_config_home(env), env)
config.upgrade() config.upgrade()
return config 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 shutil
import subprocess import subprocess
from os import environ, path
from typing import Dict, List, Optional, Union
class Notifier(): class Notifier:
def is_available(self): 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 return shutil.which(self.cmd) is not None
def notify(self, text): def notify(self, text: str) -> None:
subprocess.run(self.args(text), capture_output=True) # We do not want to raise a `CalledProcessError` on command failure.
# we don't want to print *ANYTHING* to the terminal # pylint: disable=subprocess-run-check
# We do not want to print *ANYTHING* to the terminal
# so we capture and ignore all output # so we capture and ignore all output
subprocess.run(self.args(text), capture_output=True)
def get_icon_path(self):
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data/icon-256x256.png")
if os.path.exists(path):
return path
class AppleScriptNotifier(Notifier): 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('"', '\\"') 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): 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() icon_path = self.get_icon_path()
if icon_path is not None: if icon_path is not None:
return ['notify-send', '-i', icon_path, 'asciinema', text] return [self.cmd, "-i", icon_path, "asciinema", text]
else: return [self.cmd, "asciinema", text]
return ['notify-send', 'asciinema', text]
class TerminalNotifier(Notifier): 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() icon_path = self.get_icon_path()
if icon_path is not None: if icon_path is not None:
return ['terminal-notifier', '-title', 'asciinema', '-message', text, '-appIcon', icon_path] return [
else: "terminal-notifier",
return ['terminal-notifier', '-title', 'asciinema', '-message', text] "-title",
"asciinema",
"-message",
text,
"-appIcon",
icon_path,
]
return [
"terminal-notifier",
"-title",
"asciinema",
"-message",
text,
]
class CustomCommandNotifier(Notifier): class CustomCommandNotifier(Notifier):
def __init__(self, command): def env(self, text: str) -> Dict[str, str]:
Notifier.__init__(self) icon_path = self.get_icon_path()
self.command = command env = environ.copy()
env["TEXT"] = text
if icon_path is not None:
env["ICON_PATH"] = icon_path
return env
def notify(self, text): def notify(self, text: str) -> None:
args = ['/bin/sh', '-c', self.command] # We do not want to raise a `CalledProcessError` on command failure.
env = os.environ.copy() # pylint: disable=subprocess-run-check
env['TEXT'] = text subprocess.run(
env['ICON_PATH'] = self.get_icon_path() self.args(text), env=self.env(text), capture_output=True
subprocess.run(args, env=env, capture_output=True) )
class NoopNotifier(): class NoopNotifier: # pylint: disable=too-few-public-methods
def notify(self, text): def notify(self, text: str) -> None:
pass pass
def get_notifier(enabled=True, command=None): def get_notifier(
enabled: bool = True, command: Optional[str] = None
) -> Union[Notifier, NoopNotifier]:
if enabled: if enabled:
if command: if command:
return CustomCommandNotifier(command) return CustomCommandNotifier(command)
else: for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: n = c()
n = c()
if n.is_available(): if n.is_available():
return n return n
return NoopNotifier() return NoopNotifier()

View File

@@ -1,25 +1,43 @@
import os
import sys import sys
import time import time
from typing import Any, Dict, Optional, TextIO, Union
import asciinema.asciicast.events as ev from .asciicast import events as ev
from asciinema.term import raw, read_blocking from .asciicast.v1 import Asciicast as v1
from .asciicast.v2 import Asciicast as v2
from .tty_ import raw, read_blocking
class Player: class Player: # pylint: disable=too-few-public-methods
def play(
def play(self, asciicast, idle_time_limit=None, speed=1.0, key_bindings={}): 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: try:
stdin = open('/dev/tty') with open("/dev/tty", "rt", encoding="utf-8") as stdin:
with raw(stdin.fileno()): with raw(stdin.fileno()):
self._play(asciicast, idle_time_limit, speed, stdin, key_bindings) self._play(
except Exception: asciicast, idle_time_limit, speed, stdin, key_bindings
)
except Exception: # pylint: disable=broad-except
self._play(asciicast, idle_time_limit, speed, None, key_bindings) 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 idle_time_limit = idle_time_limit or asciicast.idle_time_limit
pause_key = key_bindings.get('pause') pause_key = key_bindings.get("pause")
step_key = key_bindings.get('step') step_key = key_bindings.get("step")
stdout = asciicast.stdout_events() stdout = asciicast.stdout_events()
stdout = ev.to_relative_time(stdout) stdout = ev.to_relative_time(stdout)
@@ -30,7 +48,7 @@ class Player:
base_time = time.time() base_time = time.time()
ctrl_c = False ctrl_c = False
paused = False paused = False
pause_time = None pause_time: Optional[float] = None
for t, _type, text in stdout: for t, _type, text in stdout:
delay = t - (time.time() - base_time) delay = t - (time.time() - base_time)
@@ -46,7 +64,8 @@ class Player:
if data == pause_key: if data == pause_key:
paused = False paused = False
base_time = base_time + (time.time() - pause_time) assert pause_time is not None
base_time += time.time() - pause_time
break break
if data == step_key: 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 os
import time import time
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type
import asciinema.asciicast.v2 as v2 from . import pty_ as pty # avoid collisions with standard library `pty`
import asciinema.pty as pty from .asciicast import v2
import asciinema.term as term from .asciicast.v2 import writer as w2
from asciinema.async_worker import async_worker from .async_worker import async_worker
def record(path, command=None, append=False, idle_time_limit=None, def record( # pylint: disable=too-many-arguments,too-many-locals
rec_stdin=False, title=None, metadata=None, command_env=None, path_: str,
capture_env=None, writer=v2.writer, record=pty.record, notifier=None, command: Optional[str] = None,
key_bindings={}): 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: if command is None:
command = os.environ.get('SHELL') or 'sh' command = os.environ.get("SHELL", "sh")
if command_env is None: if command_env is None:
command_env = os.environ.copy() 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: if capture_env is None:
capture_env = ['SHELL', 'TERM'] capture_env = ["SHELL", "TERM"]
w, h = term.get_size() time_offset: float = 0
full_metadata = { if append and os.stat(path_).st_size > 0:
'width': w, time_offset = v2.get_duration(path_)
'height': h,
'timestamp': int(time.time())
}
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: sync_writer = writer(
full_metadata['idle_time_limit'] = idle_time_limit path_, metadata, append, on_error=_notifier.queue.put
)
if capture_env: with async_writer(sync_writer, time_offset, record_stdin) as _writer:
full_metadata['env'] = {var: command_env.get(var) for var in capture_env} record_(
["sh", "-c", command],
if title:
full_metadata['title'] = title
time_offset = 0
if append and os.stat(path).st_size > 0:
time_offset = v2.get_duration(path)
with async_writer(writer, path, full_metadata, append) as w:
with async_notifier(notifier) as n:
record(
['sh', '-c', command],
w,
command_env, command_env,
rec_stdin, _writer,
time_offset, get_tty_size,
n, _notifier.notify,
key_bindings 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): 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) async_worker.__init__(self)
self.writer = writer self.writer = writer
self.path = path self.time_offset = time_offset
self.metadata = metadata self.record_stdin = record_stdin
self.append = append
def write_stdin(self, ts, data): def write_stdin(self, ts: float, data: Any) -> None:
self.enqueue([ts, 'i', data]) if self.record_stdin:
self.enqueue([ts, "i", data])
def write_stdout(self, ts, data): def write_stdout(self, ts: float, data: Any) -> None:
self.enqueue([ts, 'o', data]) self.enqueue([ts, "o", data])
def run(self): def run(self) -> None:
with self.writer(self.path, metadata=self.metadata, append=self.append) as w: with self.writer as w:
event: Tuple[float, str, Any]
for event in iter(self.queue.get, None): for event in iter(self.queue.get, None):
assert event is not None
ts, etype, data = event ts, etype, data = event
if etype == 'o': if etype == "o":
w.write_stdout(ts, data) w.write_stdout(self.time_offset + ts, data)
elif etype == 'i': elif etype == "i":
w.write_stdin(ts, data) w.write_stdin(self.time_offset + ts, data)
class async_notifier(async_worker): class async_notifier(async_worker):
def __init__(self, notifier): def __init__(self, notify: Callable[[str], None]) -> None:
async_worker.__init__(self) async_worker.__init__(self)
self.notifier = notifier self._notify = notify
def notify(self, text): def notify(self, text: str) -> None:
self.enqueue(text) self.enqueue(text)
def perform(self, text): def perform(self, text: str) -> None:
try: try:
if self.notifier: self._notify(text)
self.notifier.notify(text) except: # pylint: disable=bare-except # noqa: E722
except:
# we catch *ALL* exceptions here because we don't want failed # we catch *ALL* exceptions here because we don't want failed
# notification to crash the recording session # notification to crash the recording session
pass 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 codecs
import sys
import uuid
import io
import base64
import http import http
import io
from urllib.request import Request, urlopen 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.error import HTTPError, URLError
from urllib.request import Request, urlopen
from uuid import uuid4
from .http_adapter import HTTPConnectionError from .http_adapter import HTTPConnectionError
class MultipartFormdataEncoder: class MultipartFormdataEncoder:
def __init__(self): def __init__(self) -> None:
self.boundary = uuid.uuid4().hex self.boundary = uuid4().hex
self.content_type = 'multipart/form-data; boundary={}'.format(self.boundary) self.content_type = f"multipart/form-data; boundary={self.boundary}"
@classmethod @classmethod
def u(cls, s): def u(cls, s: Any) -> Any:
if sys.hexversion >= 0x03000000 and isinstance(s, bytes): if sys.hexversion >= 0x03000000 and isinstance(s, bytes):
s = s.decode('utf-8') s = s.decode("utf-8")
return s 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. fields: {name: value} for regular form fields.
files is a dict of {name: (filename, file-type)} for data to be uploaded as files files: {name: (filename, file-type)} for data to be uploaded as files
Yield body's chunk as bytes
yield body's chunk as bytes
""" """
encoder = codecs.getencoder('utf-8') encoder = codecs.getencoder("utf-8")
for (key, value) in fields.items(): for (key, value) in fields.items():
key = self.u(key) key = self.u(key)
yield encoder('--{}\r\n'.format(self.boundary)) yield encoder(f"--{self.boundary}\r\n")
yield encoder(self.u('Content-Disposition: form-data; name="{}"\r\n').format(key)) yield encoder(
yield encoder('\r\n') self.u(f'content-disposition: form-data; name="{key}"\r\n')
if isinstance(value, int) or isinstance(value, float): )
yield encoder("\r\n")
if isinstance(value, (int, float)):
value = str(value) value = str(value)
yield encoder(self.u(value)) yield encoder(self.u(value))
yield encoder('\r\n') yield encoder("\r\n")
for (key, filename_and_f) in files.items(): for (key, filename_and_f) in files.items():
filename, f = filename_and_f filename, f = filename_and_f
key = self.u(key) key = self.u(key)
filename = self.u(filename) filename = self.u(filename)
yield encoder('--{}\r\n'.format(self.boundary)) yield encoder(f"--{self.boundary}\r\n")
yield encoder(self.u('Content-Disposition: form-data; name="{}"; filename="{}"\r\n').format(key, filename)) yield encoder(
yield encoder('Content-Type: application/octet-stream\r\n') self.u(
yield encoder('\r\n') "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() data = f.read()
yield (data, len(data)) yield (data, len(data))
yield encoder('\r\n') yield encoder("\r\n")
yield encoder('--{}--\r\n'.format(self.boundary)) 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() body = io.BytesIO()
for chunk, chunk_len in self.iter(fields, files): for chunk, _ in self.iter(fields, files):
body.write(chunk) body.write(chunk)
return self.content_type, body.getvalue() 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) content_type, body = MultipartFormdataEncoder().encode(fields, files)
headers = headers.copy() headers = headers.copy()
headers["Content-Type"] = content_type headers["content-type"] = content_type
if password: if password:
auth = "%s:%s" % (username, password) encoded_auth = b64encode(
encoded_auth = base64.b64encode(bytes(auth, "utf-8")) f"{username}:{password}".encode("utf_8")
headers["Authorization"] = b"Basic " + encoded_auth ).decode("utf_8")
headers["authorization"] = f"Basic {encoded_auth}"
request = Request(url, data=body, headers=headers, method="POST") request = Request(url, data=body, headers=headers, method="POST")
try: try:
response = urlopen(request) with urlopen(request) as response:
status = response.status status = response.status
headers = self._parse_headers(response) headers = self._parse_headers(response)
body = response.read().decode('utf-8') body = response.read().decode("utf-8")
except HTTPError as e: except HTTPError as e:
status = e.code status = e.code
headers = {} headers = {}
body = e.read().decode('utf-8') body = e.read()
except (http.client.RemoteDisconnected, URLError) as e: except (http.client.RemoteDisconnected, URLError) as e:
raise HTTPConnectionError(str(e)) raise HTTPConnectionError(str(e)) from e
return (status, headers, body) return (status, headers, body)
def _parse_headers(self, response): @staticmethod
headers = {} def _parse_headers(response: HTTPResponse) -> Dict[str, str]:
for k, v in response.getheaders(): headers = {k.lower(): v for k, v in response.getheaders()}
headers[k.lower()] = v
return headers 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 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: is not backwards compatible with version 1. Support for v2 has been added in:
* [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0 * [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0.0
(to be released, currently on development branch)
* [asciinema web player](https://github.com/asciinema/asciinema-player) - 2.6.0 * [asciinema web player](https://github.com/asciinema/asciinema-player) - 2.6.0
* [asciinema server](https://github.com/asciinema/asciinema-server) - v20171105 * [asciinema server](https://github.com/asciinema/asciinema-server) - v20171105
tag in git repository tag in git repository

View File

@@ -2,4 +2,3 @@ VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"`
asciinema.1: asciinema.1.md asciinema.1: asciinema.1.md
pandoc asciinema.1.md -s -t man -o asciinema.1 -V header:"Version $(VERSION), `date +%Y-%m-%d`" 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 .hy
.SH NAME .SH NAME
.PP .PP
\f[B]asciinema\f[] \- terminal session recorder \f[B]asciinema\f[R] - terminal session recorder
.SH SYNOPSIS .SH SYNOPSIS
.PP .PP
\f[B]asciinema \-\-version\f[] \f[B]asciinema --version\f[R]
.PD 0 .PD 0
.P .P
.PD .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 .SH DESCRIPTION
.PP .PP
asciinema lets you easily record terminal sessions and replay them in a asciinema lets you easily record terminal sessions, replay them in a
terminal as well as in a web browser. 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 .SH COMMANDS
.PP .PP
asciinema is composed of multiple commands, similar to \f[C]git\f[], asciinema is composed of multiple commands, similar to \f[V]git\f[R],
\f[C]apt\-get\f[] or \f[C]brew\f[]. \f[V]apt-get\f[R] or \f[V]brew\f[R].
.PP .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. displayed, listing all available commands with their options.
.SS rec [\f[I]filename\f[]] .SS rec [\f[I]filename\f[R]]
.PP .PP
Record terminal session. Record terminal session.
.PP .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. session.
The command (process) that is recorded can be specified with 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. is what you want in most cases.
.PP .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 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 If the recorded process is not a shell then recording finishes when the
process exits. process exits.
.PP .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 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. default to asciinema.org), where it can be watched and shared.
.PP .PP
If the \f[I]filename\f[] argument is given then the resulting recording If the \f[I]filename\f[R] argument is given then the resulting recording
(called asciicast (doc/asciicast-v2.md)) is saved to a local file. (called asciicast) is saved to a local file.
It can later be replayed with \f[B]asciinema play <filename>\f[] and/or 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[]. uploaded to asciinema server with \f[B]asciinema upload <filename>\f[R].
.PP .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. variables.
This can be used by your shell's config file (\f[C]\&.bashrc\f[], This can be used by your shell\[cq]s config file (\f[V].bashrc\f[R],
\f[C]\&.zshrc\f[]) to alter the prompt or play a sound when the shell is \f[V].zshrc\f[R]) to alter the prompt or play a sound when the shell is
being recorded. being recorded.
.TP .TP
.B Available options: Available options:
\ \
.RS .RS
.TP .TP
.B \f[C]\-\-stdin\f[] \f[V]--stdin\f[R]
Enable stdin (keyboard) recording (see below) Enable stdin (keyboard) recording (see below)
.RS
.RE
.TP .TP
.B \f[C]\-\-append\f[] \f[V]--append\f[R]
Append to existing recording Append to existing recording
.RS
.RE
.TP .TP
.B \f[C]\-\-raw\f[] \f[V]--raw\f[R]
Save raw STDOUT output, without timing information or other metadata Save raw STDOUT output, without timing information or other metadata
.RS
.RE
.TP .TP
.B \f[C]\-\-overwrite\f[] \f[V]--overwrite\f[R]
Overwrite the recording if it already exists Overwrite the recording if it already exists
.RS
.RE
.TP .TP
.B \f[C]\-c,\ \-\-command=<command>\f[] \f[V]-c, --command=<command>\f[R]
Specify command to record, defaults to \f[B]$SHELL\f[] Specify command to record, defaults to \f[B]$SHELL\f[R]
.RS
.RE
.TP .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 List of environment variables to capture, defaults to
\f[B]SHELL,TERM\f[] \f[B]SHELL,TERM\f[R]
.RS
.RE
.TP .TP
.B \f[C]\-t,\ \-\-title=<title>\f[] \f[V]-t, --title=<title>\f[R]
Specify the title of the asciicast Specify the title of the asciicast
.RS
.RE
.TP .TP
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[] \f[V]-i, --idle-time-limit=<sec>\f[R]
Limit recorded terminal inactivity to max \f[C]<sec>\f[] seconds Limit recorded terminal inactivity to max \f[V]<sec>\f[R] seconds
.RS
.RE
.TP .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) Answer \[lq]yes\[rq] to all prompts (e.g.\ upload confirmation)
.RS
.RE
.TP .TP
.B \f[C]\-q,\ \-\-quiet\f[] \f[V]-q, --quiet\f[R]
Be quiet, suppress all notices/warnings (implies \f[B]\-y\f[]) Be quiet, suppress all notices/warnings (implies \f[B]-y\f[R])
.RS
.RE
.RE .RE
.PP .PP
Stdin recording allows for capturing of all characters typed in by the Stdin recording allows for capturing of all characters typed in by the
user in the currently recorded shell. user in the currently recorded shell.
This may be used by a player (e.g. 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. display pressed keys.
Because it's basically a key\-logging (scoped to a single shell Because it\[cq]s basically a key-logging (scoped to a single shell
instance), it's disabled by default, and has to be explicitly enabled instance), it\[cq]s disabled by default, and has to be explicitly
via \f[B]\[en]stdin\f[] option. enabled via \f[B]\[en]stdin\f[R] option.
.SS play <\f[I]filename\f[]> .SS play <\f[I]filename\f[R]>
.PP .PP
Replay recorded asciicast in a terminal. Replay recorded asciicast in a terminal.
.PP .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. command) directly in your terminal.
.PP The asciicast can be read from a file or from \f[I]\f[VI]stdin\f[I]\f[R]
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
.PP .PP
Playing from a local file: Playing from a local file:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ /path/to/asciicast.cast asciinema play /path/to/asciicast.cast
\f[] \f[R]
.fi .fi
.PP .PP
Playing from HTTP(S) URL: Playing from HTTP(S) URL:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ https://asciinema.org/a/22124.cast asciinema play https://asciinema.org/a/22124.cast
asciinema\ play\ http://example.com/demo.cast asciinema play http://example.com/demo.cast
\f[] \f[R]
.fi .fi
.PP .PP
Playing from asciicast page URL (requires Playing from asciicast page URL (requires
\f[C]<link\ rel="alternate"\ type="application/x\-asciicast"\ href="/my/ascii.cast">\f[] \f[V]<link rel=\[dq]alternate\[dq] type=\[dq]application/x-asciicast\[dq] href=\[dq]/my/ascii.cast\[dq]>\f[R]
in page's HTML): in page\[cq]s HTML):
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ https://asciinema.org/a/22124 asciinema play https://asciinema.org/a/22124
asciinema\ play\ http://example.com/blog/post.html asciinema play http://example.com/blog/post.html
\f[] \f[R]
.fi .fi
.PP .PP
Playing from stdin: Playing from stdin:
.IP .IP
.nf .nf
\f[C] \f[C]
cat\ /path/to/asciicast.cast\ |\ asciinema\ play\ \- cat /path/to/asciicast.cast | asciinema play -
ssh\ user\@host\ cat\ asciicast.cast\ |\ asciinema\ play\ \- ssh user\[at]host cat asciicast.cast | asciinema play -
\f[] \f[R]
.fi .fi
.PP .PP
Playing from IPFS: Playing from IPFS:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
\f[] \f[R]
.fi .fi
.TP .TP
.B Available options: Available options:
\ \
.RS .RS
.TP .TP
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[] \f[V]-i, --idle-time-limit=<sec>\f[R]
Limit replayed terminal inactivity to max \f[C]<sec>\f[] seconds Limit replayed terminal inactivity to max \f[V]<sec>\f[R] seconds (can
.RS be fractional)
.RE
.TP .TP
.B \f[C]\-s,\ \-\-speed=<factor>\f[] \f[V]-s, --speed=<factor>\f[R]
Playback speed (can be fractional) Playback speed (can be fractional)
.RE
.TP
While playing the following keyboard shortcuts are available:
\
.RS .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
.RE .PP
.SS cat <\f[I]filename\f[]> 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 .PP
Print full output of recorded asciicast to a terminal. Print full output of recorded asciicast to a terminal.
.PP .PP
While \f[B]asciinema play \f[] replays the recorded session using timing While \f[B]asciinema play \f[R] replays the recorded session using
information saved in the asciicast, \f[B]asciinema cat \f[] dumps the timing information saved in the asciicast, \f[B]asciinema cat \f[R]
full output (including all escape sequences) to a terminal immediately. dumps the full output (including all escape sequences) to a terminal
immediately.
.PP .PP
\f[B]asciinema cat existing.cast >output.txt\f[] gives the same result \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[]. as recording via \f[B]asciinema rec --raw output.txt\f[R].
.SS upload .SS upload <\f[I]filename\f[R]>
.PP .PP
Upload recorded asciicast to asciinema.org site. Upload recorded asciicast to asciinema.org site.
.PP .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. to asciinema.org, where it can be watched and shared.
.PP .PP
\f[B]asciinema rec demo.cast\f[] + \f[B]asciinema play demo.cast\f[] + \f[B]asciinema rec demo.cast\f[R] + \f[B]asciinema play demo.cast\f[R] +
\f[B]asciinema upload demo.cast\f[] is a nice combo if you want to \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. review an asciicast before publishing it on asciinema.org.
.SS auth .SS auth
.PP .PP
Link your install ID with your asciinema.org user account. Link and manage your install ID with your asciinema.org user account.
.PP .PP
If you want to manage your recordings (change title/theme, delete) at 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. asciinema.org user account.
.PP .PP
This command displays the URL to open in a web browser to do that. 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 Install ID is a random ID (UUID
v4 (https://en.wikipedia.org/wiki/Universally_unique_identifier)) v4 (https://en.wikipedia.org/wiki/Universally_unique_identifier))
generated locally when you run asciinema for the first time, and saved generated locally when you run asciinema for the first time, and saved
at \f[B]$HOME/.config/asciinema/install\-id\f[]. at \f[B]$HOME/.config/asciinema/install-id\f[R].
It's purpose is to connect local machine with uploaded recordings, so It\[cq]s purpose is to connect local machine with uploaded recordings,
they can later be associated with asciinema.org account. so they can later be associated with asciinema.org account.
This way we decouple uploading from account creation, allowing them to This way we decouple uploading from account creation, allowing them to
happen in any order. happen in any order.
.PP .PP
Note: A new install ID is generated on each machine and system user 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 account you use asciinema on.
single asciinema.org account you need to run \f[B]asciinema auth\f[] on So in order to keep all recordings under a single asciinema.org account
all of those machines. 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 .PP
Note: asciinema versions prior to 2.0 confusingly referred to install ID Note: asciinema versions prior to 2.0 confusingly referred to install ID
as \[lq]API token\[rq]. as \[lq]API token\[rq].
@@ -247,28 +272,36 @@ Record your first session:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ rec\ first.cast asciinema rec first.cast
\f[] \f[R]
.fi
.PP
End your session:
.IP
.nf
\f[C]
exit
\f[R]
.fi .fi
.PP .PP
Now replay it with double speed: Now replay it with double speed:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ \-s\ 2\ first.cast asciinema play -s 2 first.cast
\f[] \f[R]
.fi .fi
.PP .PP
Or with normal speed but with idle time limited to 2 seconds: Or with normal speed but with idle time limited to 2 seconds:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ play\ \-i\ 2\ first.cast asciinema play -i 2 first.cast
\f[] \f[R]
.fi .fi
.PP .PP
You can pass \f[B]\-i 2\f[] to \f[B]asciinema rec\f[] as well, to set it You can pass \f[B]-i 2\f[R] to \f[B]asciinema rec\f[R] as well, to set
permanently on a recording. it permanently on a recording.
Idle time limiting makes the recordings much more interesting to watch, Idle time limiting makes the recordings much more interesting to watch,
try it. try it.
.PP .PP
@@ -276,12 +309,12 @@ If you want to watch and share it on the web, upload it:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ upload\ first.cast asciinema upload first.cast
\f[] \f[R]
.fi .fi
.PP .PP
The above uploads it to <https://asciinema.org>, which is a default 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 instance, and prints a secret link you can use to watch your recording
in a web browser. in a web browser.
.PP .PP
@@ -289,33 +322,79 @@ You can record and upload in one step by omitting the filename:
.IP .IP
.nf .nf
\f[C] \f[C]
asciinema\ rec asciinema rec
\f[] \f[R]
.fi .fi
.PP .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. nothing is sent anywhere without your consent.
.SH ENVIRONMENT .SS Tricks
.TP .TP
.B \f[B]ASCIINEMA_API_URL\f[] Record slowly, play faster:
This variable allows overriding asciinema\-server URL (which defaults to First record a session where you can take your time to type slowly what
https://asciinema.org) in case you're running your own asciinema\-server you want to show in the recording:
instance.
.RS .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 .RE
.TP .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. This variable allows overriding config directory location.
Default location is $XDG_CONFIG_HOME/asciinema (when $XDG_CONFIG_HOME is Default location is $XDG_CONFIG_HOME/asciinema (when $XDG_CONFIG_HOME is
set) or $HOME/.config/asciinema. set) or $HOME/.config/asciinema.
.RS
.RE
.SH BUGS .SH BUGS
.PP .PP
See GitHub Issues: <https://github.com/asciinema/asciinema/issues> 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 .SH AUTHORS
.PP .PP
asciinema's lead developer is Marcin Kulik. asciinema\[cq]s lead developer is Marcin Kulik.
.PP .PP
For a list of all contributors look here: For a list of all contributors look here:
<https://github.com/asciinema/asciinema/contributors> <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. below), and defaults to **$SHELL** which is what you want in most cases.
You can temporarily pause the capture of your terminal by pressing 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 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 displayed so you're sure the sensitive output won't be captured in the
recording. recording.
@@ -95,6 +95,12 @@ Available options:
`-i, --idle-time-limit=<sec>` `-i, --idle-time-limit=<sec>`
: Limit recorded terminal inactivity to max `<sec>` seconds : 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` `-y, --yes`
: Answer "yes" to all prompts (e.g. upload confirmation) : 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/) * Web: [asciinema.org/docs/](https://asciinema.org/docs/)
* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) * 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) * 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> 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. 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] [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 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] [pycodestyle]
ignore = E501,E402,E722 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 json
import tempfile
from asciinema.asciicast import v2
from ..test_helper import Test
class TestWriter(Test): class TestWriter(Test):
@staticmethod
def test_writing(self): def test_writing() -> None:
_file, path = tempfile.mkstemp() _file, path = tempfile.mkstemp()
with v2.writer(path, width=80, height=24) as w: 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(1, "x") # ensure it supports both str and bytes
w.write_stdout(2, bytes.fromhex('78 c5 bc c3 b3 c5')) w.write_stdout(2, bytes.fromhex("78 c5 bc c3 b3 c5"))
w.write_stdout(3, bytes.fromhex('82 c4 87')) w.write_stdout(3, bytes.fromhex("82 c4 87"))
w.write_stdout(4, bytes.fromhex('78 78')) w.write_stdout(4, bytes.fromhex("78 78"))
with open(path, 'r') as f: with open(path, "rt", encoding="utf_8") as f:
lines = list(map(json.loads, f.read().strip().split('\n'))) lines = list(map(json.loads, f.read().strip().split("\n")))
assert lines == [{"version": 2, "width": 80, "height": 24}, assert lines == [
[1, "o", "x"], {"version": 2, "width": 80, "height": 24},
[2, "o", "xżó"], [1, "o", "x"],
[3, "o", "łć"], [2, "o", "xżó"],
[4, "o", "xx"]], 'got:\n\n%s' % lines [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 re
import tempfile
from os import path
from typing import Dict, Optional
import asciinema.config as cfg import asciinema.config as cfg
from asciinema.config import Config
def create_config(content=None, env={}): def create_config(
dir = tempfile.mkdtemp() content: Optional[str] = None, env: Optional[Dict[str, str]] = None
) -> Config:
# avoid redefining `dir` builtin
dir_ = tempfile.mkdtemp()
if content: if content:
path = dir + '/config' # avoid redefining `os.path`
with open(path, 'w') as f: path_ = f"{dir_}/config"
with open(path_, "wt", encoding="utf_8") as f:
f.write(content) f.write(content)
return cfg.Config(dir, env) return cfg.Config(dir_, env)
def read_install_id(install_id_path): def read_install_id(install_id_path: str) -> str:
with open(install_id_path, 'r') as f: with open(install_id_path, "rt", encoding="utf_8") as f:
return f.read().strip() return f.read().strip()
def test_upgrade_no_config_file(): def test_upgrade_no_config_file() -> None:
config = create_config() config = create_config()
config.upgrade() config.upgrade()
install_id = read_install_id(config.install_id_path) 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 re.match("^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}", install_id)
assert_equal(install_id, config.install_id) assert install_id == config.install_id
assert not path.exists(config.config_file_path) assert not path.exists(config.config_file_path)
# it must not change after another upgrade # it must not change after another upgrade
config.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 = create_config("[api]\ntoken = foo-bar-baz")
config.upgrade() 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"
assert_equal(config.install_id, 'foo-bar-baz') assert config.install_id == "foo-bar-baz"
assert not path.exists(config.config_file_path) assert not path.exists(config.config_file_path)
config.upgrade() 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(): def test_upgrade_config_file_with_api_token_and_more() -> None:
config = create_config("[api]\ntoken = foo-bar-baz\nurl = http://example.com") config = create_config(
"[api]\ntoken = foo-bar-baz\nurl = http://example.com"
)
config.upgrade() 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"
assert_equal(config.install_id, 'foo-bar-baz') assert config.install_id == "foo-bar-baz"
assert_equal(config.api_url, 'http://example.com') assert config.api_url == "http://example.com"
assert path.exists(config.config_file_path) assert path.exists(config.config_file_path)
config.upgrade() 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 = create_config("[user]\ntoken = foo-bar-baz")
config.upgrade() 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"
assert_equal(config.install_id, 'foo-bar-baz') assert config.install_id == "foo-bar-baz"
assert not path.exists(config.config_file_path) assert not path.exists(config.config_file_path)
config.upgrade() 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(): 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 = create_config(
"[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com"
)
config.upgrade() 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"
assert_equal(config.install_id, 'foo-bar-baz') assert config.install_id == "foo-bar-baz"
assert_equal(config.api_url, 'http://example.com') assert config.api_url == "http://example.com"
assert path.exists(config.config_file_path) assert path.exists(config.config_file_path)
config.upgrade() 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(): def test_default_api_url() -> None:
config = create_config('') config = create_config("")
assert_equal('https://asciinema.org', config.api_url) assert config.api_url == "https://asciinema.org"
def test_default_record_stdin(): def test_default_record_stdin() -> None:
config = create_config('') config = create_config("")
assert_equal(False, config.record_stdin) assert config.record_stdin is False
def test_default_record_command(): def test_default_record_command() -> None:
config = create_config('') config = create_config("")
assert_equal(None, config.record_command) assert config.record_command is None
def test_default_record_env(): def test_default_record_env() -> None:
config = create_config('') config = create_config("")
assert_equal('SHELL,TERM', config.record_env) assert config.record_env == "SHELL,TERM"
def test_default_record_idle_time_limit(): def test_default_record_idle_time_limit() -> None:
config = create_config('') config = create_config("")
assert_equal(None, config.record_idle_time_limit) assert config.record_idle_time_limit is None
def test_default_record_yes(): def test_default_record_yes() -> None:
config = create_config('') config = create_config("")
assert_equal(False, config.record_yes) assert config.record_yes is False
def test_default_record_quiet(): def test_default_record_quiet() -> None:
config = create_config('') config = create_config("")
assert_equal(False, config.record_quiet) assert config.record_quiet is False
def test_default_play_idle_time_limit(): def test_default_play_idle_time_limit() -> None:
config = create_config('') config = create_config("")
assert_equal(None, config.play_idle_time_limit) 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") 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(): def test_api_url_when_override_set() -> None:
config = create_config("[api]\nurl = http://the/url", { config = create_config(
'ASCIINEMA_API_URL': 'http://the/url2'}) "[api]\nurl = http://the/url", {"ASCIINEMA_API_URL": "http://the/url2"}
assert_equal('http://the/url2', config.api_url) )
assert config.api_url == "http://the/url2"
def test_record_command(): def test_record_command() -> None:
command = 'bash -l' command = "bash -l"
config = create_config("[record]\ncommand = %s" % command) config = create_config(f"[record]\ncommand = {command}")
assert_equal(command, config.record_command) assert config.record_command == command
def test_record_stdin(): def test_record_stdin() -> None:
config = create_config("[record]\nstdin = yes") 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") 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") 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") 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(): def test_record_yes() -> None:
yes = 'yes' yes = "yes"
config = create_config("[record]\nyes = %s" % yes) config = create_config(f"[record]\nyes = {yes}")
assert_equal(True, config.record_yes) assert config.record_yes is True
def test_record_quiet(): def test_record_quiet() -> None:
quiet = 'yes' quiet = "yes"
config = create_config("[record]\nquiet = %s" % quiet) config = create_config(f"[record]\nquiet = {quiet}")
assert_equal(True, config.record_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") 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") 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(): def test_notifications_enabled() -> None:
config = create_config('') config = create_config("")
assert_equal(True, config.notifications_enabled) assert config.notifications_enabled is True
config = create_config("[notifications]\nenabled = yes") config = create_config("[notifications]\nenabled = yes")
assert_equal(True, config.notifications_enabled) assert config.notifications_enabled is True
config = create_config("[notifications]\nenabled = no") config = create_config("[notifications]\nenabled = no")
assert_equal(False, config.notifications_enabled) assert config.notifications_enabled is False
def test_notifications_command(): def test_notifications_command() -> None:
config = create_config('') config = create_config("")
assert_equal(None, config.notifications_command) assert config.notifications_command is None
config = create_config('[notifications]\ncommand = tmux display-message "$TEXT"') config = create_config(
assert_equal('tmux display-message "$TEXT"', config.notifications_command) '[notifications]\ncommand = tmux display-message "$TEXT"'
)
assert config.notifications_command == 'tmux display-message "$TEXT"'

View File

@@ -111,4 +111,4 @@
"\r\n" "\r\n"
] ]
] ]
} }

View File

@@ -1,22 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -euo pipefail
path_to_self="${BASH_SOURCE[0]}" readonly DISTROS=(
tests_dir="$(cd "$(dirname "$path_to_self")" && pwd)" 'arch'
'alpine'
'centos'
'debian'
'fedora'
'ubuntu'
)
test() { readonly DOCKER='docker'
printf "\e[1;32mTesting on $1...\e[0m\n"
echo
docker build -t asciinema/asciinema:$1 -f tests/distros/Dockerfile.$1 . # do not redefine builtin `test`
docker run --rm -ti asciinema/asciinema:$1 tests/integration.sh 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 for distro in "${DISTROS[@]}"; do
printf "\e[1;32mAll tests passed.\e[0m\n" 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 WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests COPY asciinema/ asciinema/
ENV LANG en_US.utf8 COPY tests/ tests/
ENV SHELL /bin/bash
ENV USER docker 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 WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests COPY asciinema/ asciinema/
ENV LANG en_US.utf8 COPY tests/ tests/
ENV SHELL /bin/bash
ENV USER docker 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 WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests COPY asciinema/ asciinema/
ENV LANG en_US.utf8 COPY tests/ tests/
ENV SHELL /bin/bash
ENV USER docker 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 WORKDIR /usr/src/app
COPY asciinema asciinema
COPY tests tests COPY asciinema/ asciinema/
ENV LANG en_US.utf8 COPY tests/ tests/
ENV SHELL /bin/bash
ENV USER docker ENV LANG="en_US.utf8"
USER nobody
ENTRYPOINT ["/bin/bash"]
# vim:ft=dockerfile

View File

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

View File

@@ -1,41 +1,54 @@
import os import os
import pty import pty
from typing import Any, List, Union
import asciinema.pty_
from nose.tools import assert_equal
from .test_helper import Test from .test_helper import Test
import asciinema.pty
class Writer:
def __init__(self) -> None:
self.data: List[Union[float, str]] = []
class FakeStdout: def write_stdout(self, _ts: float, data: Any) -> None:
def __init__(self):
self.data = []
def write_stdout(self, ts, data):
self.data.append(data) self.data.append(data)
def write_stdin(self, ts, data): def write_stdin(self, ts: float, data: Any) -> None:
pass raise NotImplementedError
class TestRecord(Test): class TestRecord(Test):
def setUp(self) -> None:
def setUp(self):
self.real_os_write = os.write 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 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: if fd != pty.STDOUT_FILENO:
self.real_os_write(fd, data) self.real_os_write(fd, data)
def test_record_command_writes_to_stdout(self): @staticmethod
output = FakeStdout() 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\')"] command = [
asciinema.pty.record(command, output) "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 import sys
try: from codecs import StreamReader
from StringIO import StringIO from io import StringIO
except ImportError: from typing import Optional, TextIO, Union
from io import StringIO
stdout: Optional[Union[TextIO, StreamReader]] = None
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
class Test: class Test:
def setUp(self) -> None:
def setUp(self): global stdout # pylint: disable=global-statement
global stdout
self.real_stdout = sys.stdout self.real_stdout = sys.stdout
sys.stdout = stdout = StringIO() sys.stdout = stdout = StringIO()
def tearDown(self): def tearDown(self) -> None:
sys.stdout = self.real_stdout 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 {}