mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-16 11:48:13 +01:00
Merge branch 'develop'
This commit is contained in:
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
110
.github/workflows/asciinema.yml
vendored
110
.github/workflows/asciinema.yml
vendored
@@ -1,44 +1,110 @@
|
||||
---
|
||||
name: build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
jobs:
|
||||
# Code style checks
|
||||
health:
|
||||
name: Code health check
|
||||
name: code health check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Asciinema
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
- name: checkout asciinema
|
||||
uses: actions/checkout@v3
|
||||
- name: setup Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: pip install pycodestyle twine setuptools>=38.6.0 cmarkgfm
|
||||
python-version: "3.10"
|
||||
- name: install dependencies
|
||||
run: pip install build cmarkgfm pycodestyle twine
|
||||
- name: Run pycodestyle
|
||||
run: find . -name \*.py -exec pycodestyle --ignore=E501,E402,E722 {} +
|
||||
run: >
|
||||
find .
|
||||
-name '*\.py'
|
||||
-exec pycodestyle --ignore=E402,E501,E722,W503 "{}" \+
|
||||
- name: Run twine
|
||||
run: |
|
||||
python setup.py --quiet sdist
|
||||
python3 -m build
|
||||
twine check dist/*
|
||||
# Asciinema checks
|
||||
asciinema:
|
||||
name: Asciinema - py${{ matrix.python }}
|
||||
name: Asciinema
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python: [3.6, 3.7, 3.8, 3.9]
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
env:
|
||||
TERM: dumb
|
||||
steps:
|
||||
- name: Checkout Asciinema
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
- name: checkout Asciinema
|
||||
uses: actions/checkout@v3
|
||||
- name: setup Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Install dependencies
|
||||
run: pip install nose
|
||||
- name: Run Asciinema tests
|
||||
- name: install dependencies
|
||||
run: pip install pytest
|
||||
- name: run Asciinema tests
|
||||
run: script -e -c make test
|
||||
build_distros:
|
||||
name: build distro images
|
||||
strategy:
|
||||
matrix:
|
||||
distros:
|
||||
- alpine
|
||||
- arch
|
||||
- centos
|
||||
- debian
|
||||
- fedora
|
||||
- ubuntu
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Authenticate to GHCR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: "${{ github.actor }}"
|
||||
password: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: "Build ${{ matrix.distros }} image"
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
file: "tests/distros/Dockerfile.${{ matrix.distros }}"
|
||||
tags: |
|
||||
"ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
|
||||
push: true
|
||||
test_distros:
|
||||
name: integration test distro images
|
||||
needs: build_distros
|
||||
strategy:
|
||||
matrix:
|
||||
distros:
|
||||
- alpine
|
||||
- arch
|
||||
- centos
|
||||
- debian
|
||||
- fedora
|
||||
- ubuntu
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: "ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
|
||||
credentials:
|
||||
username: "${{ github.actor }}"
|
||||
password: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# https://github.community/t/permission-problems-when-checking-out-code-as-part-of-github-action/202263
|
||||
options: "--interactive --tty --user=1001:121"
|
||||
steps:
|
||||
- name: checkout Asciinema
|
||||
uses: actions/checkout@v3
|
||||
- name: run integration tests
|
||||
env:
|
||||
TERM: dumb
|
||||
shell: 'script --return --quiet --command "bash {0}"'
|
||||
run: make test.integration
|
||||
|
||||
14
.github/workflows/pre-commit.yml
vendored
Normal file
14
.github/workflows/pre-commit.yml
vendored
Normal 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
39
.pre-commit-config.yaml
Normal 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
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# asciinema changelog
|
||||
|
||||
## 2.2.0 (2022-05-07)
|
||||
|
||||
* Added official support for Python 3.8, 3.9, 3.10
|
||||
* Dropped official support for Python 3.5
|
||||
* Added `--cols` / `--rows` options for overriding size of pseudo-terminal reported to recorded program
|
||||
* Improved behaviour of `--append` when output file doesn't exist
|
||||
* Keyboard input is now explicitly read from a TTY device in addition to stdin (when stdin != TTY)
|
||||
* Recorded program output is now explicitly written to a TTY device instead of stdout
|
||||
* Dash char (`-`) can now be passed as output filename to write asciicast to stdout
|
||||
* Diagnostic messages are now printed to stderr (without colors when stderr != TTY)
|
||||
* Improved robustness of writing asciicast to named pipes
|
||||
* Lots of codebase modernizations (many thanks to Davis @djds Schirmer!)
|
||||
* Many other internal refactorings
|
||||
|
||||
## 2.1.0 (2021-10-02)
|
||||
|
||||
* Ability to pause/resume terminal capture with `C-\` key shortcut
|
||||
@@ -12,7 +26,6 @@
|
||||
* Upload for users with very long `$USER` is fixed
|
||||
* Added official support for Python 3.8 and 3.9
|
||||
* Dropped official support for EOL-ed Python 3.4 and 3.5
|
||||
* Internal refactorings
|
||||
|
||||
## 2.0.2 (2019-01-12)
|
||||
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
# Contributing to asciinema
|
||||
|
||||
First, if you're opening a Github issue make sure it goes to the correct repository:
|
||||
First, if you're opening a GitHub issue make sure it goes to the correct
|
||||
repository:
|
||||
|
||||
* [asciinema/asciinema](https://github.com/asciinema/asciinema/issues) - command-line recorder
|
||||
* [asciinema/asciinema-server](https://github.com/asciinema/asciinema-server/issues) - public website hosting recordings
|
||||
* [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player/issues) - player
|
||||
- [asciinema/asciinema](https://github.com/asciinema/asciinema/issues) - command-line recorder
|
||||
- [asciinema/asciinema-server](https://github.com/asciinema/asciinema-server/issues) - public website hosting recordings
|
||||
- [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player/issues) - player
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue in Github issue tracker.
|
||||
Open an issue in GitHub issue tracker.
|
||||
|
||||
Tell us what's the problem and include steps to reproduce it (reliably).
|
||||
Including your OS/browser/terminal name and version in the report would be great.
|
||||
Including your OS/browser/terminal name and version in the report would be
|
||||
great.
|
||||
|
||||
## Submitting patches with bug fixes
|
||||
|
||||
If you found a bug and made a patch for it:
|
||||
|
||||
* make sure all tests pass
|
||||
* send us a pull request, including a description of the fix (referencing an existing issue if there's one)
|
||||
1. Make sure your changes pass the [pre-commit](https://pre-commit.com/)
|
||||
[hooks](.pre-commit-config.yaml). You can install the hooks in your work
|
||||
tree by running `pre-commit install` in your checked out copy.
|
||||
1. Make sure all tests pass. If you add new functionality, add new tests.
|
||||
1. Send us a pull request, including a description of the fix (referencing an
|
||||
existing issue if there's one).
|
||||
|
||||
## Requesting new features
|
||||
|
||||
We welcome all ideas.
|
||||
If you believe most asciinema users would benefit from implementing your idea then feel free to open a Github issue.
|
||||
However, as this is an open-source project maintained by a small team of volunteers we simply can't implement all of them due to limited resources. Please keep that in mind.
|
||||
|
||||
If you believe most asciinema users would benefit from implementing your idea
|
||||
then feel free to open a GitHub issue. However, as this is an open-source
|
||||
project maintained by a small team of volunteers we simply can't implement all
|
||||
of them due to limited resources. Please keep that in mind.
|
||||
|
||||
## Proposing features/changes (pull requests)
|
||||
|
||||
If you want to propose code change, either introducing a new feature or improving an existing one, please first discuss this with asciinema team. You can simply open a separate issue for a discussion or join #asciinema IRC channel on freenode.
|
||||
If you want to propose code change, either introducing a new feature or
|
||||
improving an existing one, please first discuss this with asciinema team. You
|
||||
can simply open a separate issue for a discussion or join #asciinema IRC
|
||||
channel on Libera.Chat.
|
||||
|
||||
## Asking for help
|
||||
|
||||
Github issue tracker is not a support forum.
|
||||
GitHub issue tracker is not a support forum.
|
||||
|
||||
If you need help then either join #asciinema IRC channel on libera.chat or drop
|
||||
us an email at support@asciinema.org.
|
||||
If you need help then either join #asciinema IRC channel on Libera.Chat or
|
||||
drop us an email at <support@asciinema.org>.
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
If you found a security issue in asciinema please contact us at support@asciinema.org.
|
||||
For the benefit of all asciinema users please **do not** publish details of the vulnerability in a Github issue.
|
||||
If you found a security issue in asciinema please contact us at
|
||||
support@asciinema.org. For the benefit of all asciinema users please **do
|
||||
not** publish details of the vulnerability in a GitHub issue.
|
||||
|
||||
The PGP key below (1eb33a8760dec34b) can be used when sending encrypted email to or verifying responses from support@asciinema.org.
|
||||
The PGP key below (1eb33a8760dec34b) can be used when sending encrypted email
|
||||
to or verifying responses from support@asciinema.org.
|
||||
|
||||
```
|
||||
```Public Key
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v2
|
||||
|
||||
|
||||
48
Dockerfile
48
Dockerfile
@@ -1,22 +1,38 @@
|
||||
FROM ubuntu:20.04
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
python3 \
|
||||
python3-setuptools
|
||||
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
|
||||
RUN mkdir /usr/src/app
|
||||
COPY setup.cfg /usr/src/app
|
||||
COPY setup.py /usr/src/app
|
||||
COPY *.md /usr/src/app/
|
||||
FROM docker.io/library/ubuntu:20.04
|
||||
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& localedef \
|
||||
-i en_US \
|
||||
-c \
|
||||
-f UTF-8 \
|
||||
-A /usr/share/locale/locale.alias \
|
||||
en_US.UTF-8
|
||||
|
||||
COPY pyproject.toml setup.cfg *.md /usr/src/app/
|
||||
COPY doc/*.md /usr/src/app/doc/
|
||||
COPY man/asciinema.1 /usr/src/app/man/
|
||||
COPY asciinema /usr/src/app/asciinema
|
||||
COPY asciinema/ /usr/src/app/asciinema/
|
||||
COPY README.md LICENSE /usr/src/app/
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
RUN python3 setup.py install
|
||||
ENV LANG en_US.utf8
|
||||
ENV SHELL /bin/bash
|
||||
ENV USER docker
|
||||
|
||||
RUN pip3 install .
|
||||
|
||||
WORKDIR /root
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
ENV SHELL="/bin/bash"
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/asciinema"]
|
||||
CMD ["--help"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
|
||||
76
Makefile
76
Makefile
@@ -1,31 +1,75 @@
|
||||
NAME=asciinema
|
||||
VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"`
|
||||
NAME := asciinema
|
||||
VERSION := $(shell python3 -c "import asciinema; print(asciinema.__version__)")
|
||||
|
||||
test: test-unit test-integration
|
||||
VIRTUAL_ENV ?= .venv
|
||||
|
||||
test-unit:
|
||||
nosetests
|
||||
.PHONY: test
|
||||
test: test.unit test.integration
|
||||
|
||||
test-integration:
|
||||
.PHONY: test.unit
|
||||
test.unit:
|
||||
pytest
|
||||
|
||||
.PHONY: test.integration
|
||||
test.integration:
|
||||
tests/integration.sh
|
||||
|
||||
.PHONY: test.distros
|
||||
test.distros:
|
||||
tests/distros.sh
|
||||
|
||||
.PHONY: release
|
||||
release: test tag push
|
||||
|
||||
release-test: test push-test
|
||||
.PHONY: release.test
|
||||
release.test: test push.test
|
||||
|
||||
tag:
|
||||
git tag | grep "v$(VERSION)" && echo "Tag v$(VERSION) exists" && exit 1 || true
|
||||
.PHONY: .tag.exists
|
||||
.tag.exists:
|
||||
@git tag \
|
||||
| grep -q "v$(VERSION)" \
|
||||
&& echo "Tag v$(VERSION) exists" \
|
||||
&& exit 1
|
||||
|
||||
.PHONY: tag
|
||||
tag: .tag.exists
|
||||
git tag -s -m "Releasing $(VERSION)" v$(VERSION)
|
||||
git push origin v$(VERSION)
|
||||
|
||||
push:
|
||||
python3 -m pip install --user --upgrade --quiet twine
|
||||
python3 setup.py sdist bdist_wheel
|
||||
.PHONY: .venv
|
||||
.venv:
|
||||
python3 -m venv $(VIRTUAL_ENV)
|
||||
|
||||
.PHONY: .pip
|
||||
.pip: .venv
|
||||
. $(VIRTUAL_ENV)/bin/activate \
|
||||
&& python3 -m pip install --upgrade build twine
|
||||
|
||||
build: .pip
|
||||
. $(VIRTUAL_ENV)/bin/activate \
|
||||
&& python3 -m build .
|
||||
|
||||
install: build
|
||||
. $(VIRTUAL_ENV)/bin/activate \
|
||||
&& python3 -m pip install .
|
||||
|
||||
.PHONY: push
|
||||
push: .pip build
|
||||
python3 -m twine upload dist/*
|
||||
|
||||
push-test:
|
||||
python3 -m pip install --user --upgrade --quiet twine
|
||||
python3 setup.py sdist bdist_wheel
|
||||
.PHONY: push.test
|
||||
push.test: .pip build
|
||||
python3 -m twine upload --repository testpypi dist/*
|
||||
|
||||
.PHONY: test test-unit test-integration release release-test tag push push-test
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf dist *.egg-info
|
||||
|
||||
clean.all: clean
|
||||
find . \
|
||||
-type d \
|
||||
-name __pycache__ \
|
||||
-o -name .pytest_cache \
|
||||
-o -name .mypy_cache \
|
||||
-exec rm -r "{}" +
|
||||
|
||||
180
README.md
180
README.md
@@ -14,21 +14,36 @@ Terminal session recorder and the best companion of
|
||||
asciinema lets you easily record terminal sessions and replay
|
||||
them in a terminal as well as in a web browser.
|
||||
|
||||
Install latest version ([other installation options](#installation)):
|
||||
Install latest version ([other installation options](#installation))
|
||||
using [pipx](https://pypa.github.io/pipx/) (if you have it):
|
||||
|
||||
sudo pip3 install asciinema
|
||||
```sh
|
||||
pipx install asciinema
|
||||
```
|
||||
|
||||
If you don't have pipx, install using pip with your preferred Python version:
|
||||
|
||||
```sh
|
||||
python3 -m pip install asciinema
|
||||
```
|
||||
|
||||
Record your first session:
|
||||
|
||||
asciinema rec first.cast
|
||||
```sh
|
||||
asciinema rec first.cast
|
||||
```
|
||||
|
||||
Now replay it with double speed:
|
||||
|
||||
asciinema play -s 2 first.cast
|
||||
```sh
|
||||
asciinema play -s 2 first.cast
|
||||
```
|
||||
|
||||
Or with normal speed but with idle time limited to 2 seconds:
|
||||
|
||||
asciinema play -i 2 first.cast
|
||||
```sh
|
||||
asciinema play -i 2 first.cast
|
||||
```
|
||||
|
||||
You can pass `-i 2` to `asciinema rec` as well, to set it permanently on a
|
||||
recording. Idle time limiting makes the recordings much more interesting to
|
||||
@@ -36,7 +51,9 @@ watch. Try it.
|
||||
|
||||
If you want to watch and share it on the web, upload it:
|
||||
|
||||
asciinema upload first.cast
|
||||
```sh
|
||||
asciinema upload first.cast
|
||||
```
|
||||
|
||||
The above uploads it to [asciinema.org](https://asciinema.org), which is a
|
||||
default [asciinema-server](https://github.com/asciinema/asciinema-server)
|
||||
@@ -45,7 +62,9 @@ browser.
|
||||
|
||||
You can record and upload in one step by omitting the filename:
|
||||
|
||||
asciinema rec
|
||||
```sh
|
||||
asciinema rec
|
||||
```
|
||||
|
||||
You'll be asked to confirm the upload when the recording is done. Nothing is
|
||||
sent anywhere without your consent.
|
||||
@@ -55,15 +74,25 @@ cover installation, usage and hosting of the recordings in more detail.
|
||||
|
||||
## Installation
|
||||
|
||||
### Python package
|
||||
### Python package from PyPI
|
||||
|
||||
asciinema is available on [PyPI](https://pypi.python.org/pypi/asciinema) and can
|
||||
be installed with pip (Python 3 with setuptools required):
|
||||
[pypi]: https://pypi.python.org/pypi/asciinema
|
||||
|
||||
sudo pip3 install asciinema
|
||||
asciinema is available on [PyPI] and can be installed with
|
||||
[pipx](https://pypa.github.io/pipx/) (if you have it) or with pip (Python 3
|
||||
with setuptools required):
|
||||
|
||||
This is the recommended way of installation, which gives you the latest released
|
||||
version.
|
||||
```sh
|
||||
pipx install asciinema
|
||||
```
|
||||
|
||||
Or with pip (using your preferred Python version):
|
||||
|
||||
```sh
|
||||
python3 -m pip install asciinema
|
||||
```
|
||||
|
||||
Installing from [PyPI] is the recommended way of installation, which gives you the latest released version.
|
||||
|
||||
### Native packages
|
||||
|
||||
@@ -78,32 +107,45 @@ can clone the repo and run asciinema straight from the checkout.
|
||||
|
||||
Clone the repo:
|
||||
|
||||
git clone https://github.com/asciinema/asciinema.git
|
||||
cd asciinema
|
||||
```sh
|
||||
git clone https://github.com/asciinema/asciinema.git
|
||||
cd asciinema
|
||||
```
|
||||
|
||||
If you want latest stable version:
|
||||
|
||||
git checkout master
|
||||
```sh
|
||||
git checkout master
|
||||
```
|
||||
|
||||
If you want current development version:
|
||||
|
||||
git checkout develop
|
||||
```sh
|
||||
git checkout develop
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
|
||||
python3 -m asciinema --version
|
||||
```sh
|
||||
python3 -m asciinema --version
|
||||
```
|
||||
|
||||
### Docker image
|
||||
|
||||
asciinema Docker image is based on Ubuntu 18.04 and has the latest version of
|
||||
asciinema Docker image is based on [Ubuntu
|
||||
20.04](https://releases.ubuntu.com/20.04/) and has the latest version of
|
||||
asciinema recorder pre-installed.
|
||||
|
||||
docker pull asciinema/asciinema
|
||||
```sh
|
||||
docker pull docker.io/asciinema/asciinema
|
||||
```
|
||||
|
||||
When running it don't forget to allocate a pseudo-TTY (`-t`), keep STDIN open
|
||||
(`-i`) and mount config directory volume (`-v`):
|
||||
|
||||
docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema rec
|
||||
```sh
|
||||
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" docker.io/asciinema/asciinema rec
|
||||
```
|
||||
|
||||
Container's entrypoint is set to `/usr/local/bin/asciinema` so you can run the
|
||||
container with any arguments you would normally pass to `asciinema` binary (see
|
||||
@@ -111,13 +153,28 @@ Usage section for commands and options).
|
||||
|
||||
There's not much software installed in this image though. In most cases you may
|
||||
want to install extra programs before recording. One option is to derive new
|
||||
image from this one (start your custom Dockerfile with `FROM
|
||||
asciinema/asciinema`). Another option is to start the container with `/bin/bash`
|
||||
image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash`
|
||||
as the entrypoint, install extra packages and manually start `asciinema rec`:
|
||||
|
||||
docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema --entrypoint=/bin/bash asciinema/asciinema
|
||||
root@6689517d99a1:~# apt-get install foobar
|
||||
root@6689517d99a1:~# asciinema rec
|
||||
```console
|
||||
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash docker.io/asciinema/asciinema rec
|
||||
root@6689517d99a1:~# apt-get install foobar
|
||||
root@6689517d99a1:~# asciinema rec
|
||||
```
|
||||
|
||||
It is also possible to run the docker container as a non-root user, which has
|
||||
security benefits. You can specify a user and group id at runtime to give the
|
||||
application permission similar to the calling user on your host.
|
||||
|
||||
```sh
|
||||
docker run --rm -it \
|
||||
--env=ASCIINEMA_CONFIG_HOME="/run/user/$(id -u)/.config/asciinema" \
|
||||
--user="$(id -u):$(id -g)" \
|
||||
--volume="${HOME}/.config/asciinema:/run/user/$(id -u)/.config/asciinema:rw" \
|
||||
--volume="${PWD}:/data:rw" \
|
||||
--workdir='/data' \
|
||||
docker.io/asciinema/asciinema rec
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -129,7 +186,7 @@ all available commands with their options.
|
||||
|
||||
### `rec [filename]`
|
||||
|
||||
__Record terminal session.__
|
||||
**Record terminal session.**
|
||||
|
||||
By running `asciinema rec [filename]` you start a new recording session. The
|
||||
command (process) that is recorded can be specified with `-c` option (see
|
||||
@@ -162,17 +219,19 @@ prompt or play a sound when the shell is being recorded.
|
||||
|
||||
Available options:
|
||||
|
||||
* `--stdin` - Enable stdin (keyboard) recording (see below)
|
||||
* `--append` - Append to existing recording
|
||||
* `--raw` - Save raw STDOUT output, without timing information or other metadata
|
||||
* `--overwrite` - Overwrite the recording if it already exists
|
||||
* `-c, --command=<command>` - Specify command to record, defaults to $SHELL
|
||||
* `-e, --env=<var-names>` - List of environment variables to capture, defaults
|
||||
- `--stdin` - Enable stdin (keyboard) recording (see below)
|
||||
- `--append` - Append to existing recording
|
||||
- `--raw` - Save raw STDOUT output, without timing information or other metadata
|
||||
- `--overwrite` - Overwrite the recording if it already exists
|
||||
- `-c, --command=<command>` - Specify command to record, defaults to $SHELL
|
||||
- `-e, --env=<var-names>` - List of environment variables to capture, defaults
|
||||
to `SHELL,TERM`
|
||||
* `-t, --title=<title>` - Specify the title of the asciicast
|
||||
* `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
|
||||
* `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation)
|
||||
* `-q, --quiet` - Be quiet, suppress all notices/warnings (implies -y)
|
||||
- `-t, --title=<title>` - Specify the title of the asciicast
|
||||
- `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
|
||||
- `--cols=<n>` - Override terminal columns for recorded process
|
||||
- `--rows=<n>` - Override terminal rows for recorded process
|
||||
- `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation)
|
||||
- `-q, --quiet` - Be quiet, suppress all notices/warnings (implies -y)
|
||||
|
||||
Stdin recording allows for capturing of all characters typed in by the user in
|
||||
the currently recorded shell. This may be used by a player (e.g.
|
||||
@@ -183,7 +242,7 @@ instance), it's disabled by default, and has to be explicitly enabled via
|
||||
|
||||
### `play <filename>`
|
||||
|
||||
__Replay recorded asciicast in a terminal.__
|
||||
**Replay recorded asciicast in a terminal.**
|
||||
|
||||
This command replays given asciicast (as recorded by `rec` command) directly in
|
||||
your terminal.
|
||||
@@ -199,32 +258,41 @@ keyboard shortcuts.
|
||||
|
||||
Playing from a local file:
|
||||
|
||||
asciinema play /path/to/asciicast.cast
|
||||
```sh
|
||||
asciinema play /path/to/asciicast.cast
|
||||
```
|
||||
|
||||
Playing from HTTP(S) URL:
|
||||
|
||||
asciinema play https://asciinema.org/a/22124.cast
|
||||
asciinema play http://example.com/demo.cast
|
||||
```sh
|
||||
asciinema play https://asciinema.org/a/22124.cast
|
||||
asciinema play http://example.com/demo.cast
|
||||
```
|
||||
|
||||
Playing from asciicast page URL (requires `<link rel="alternate"
|
||||
type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
|
||||
Playing from asciicast page URL (requires `<link rel="alternate" type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
|
||||
|
||||
asciinema play https://asciinema.org/a/22124
|
||||
asciinema play http://example.com/blog/post.html
|
||||
```sh
|
||||
asciinema play https://asciinema.org/a/22124
|
||||
asciinema play http://example.com/blog/post.html
|
||||
```
|
||||
|
||||
Playing from stdin:
|
||||
|
||||
cat /path/to/asciicast.cast | asciinema play -
|
||||
ssh user@host cat asciicast.cast | asciinema play -
|
||||
```sh
|
||||
cat /path/to/asciicast.cast | asciinema play -
|
||||
ssh user@host cat asciicast.cast | asciinema play -
|
||||
```
|
||||
|
||||
Playing from IPFS:
|
||||
|
||||
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
|
||||
```sh
|
||||
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
|
||||
```
|
||||
|
||||
Available options:
|
||||
|
||||
* `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
|
||||
* `-s, --speed=<factor>` - Playback speed (can be fractional)
|
||||
- `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
|
||||
- `-s, --speed=<factor>` - Playback speed (can be fractional)
|
||||
|
||||
> For the best playback experience it is recommended to run `asciinema play` in
|
||||
> a terminal of dimensions not smaller than the one used for recording, as
|
||||
@@ -232,7 +300,7 @@ Available options:
|
||||
|
||||
### `cat <filename>`
|
||||
|
||||
__Print full output of recorded asciicast to a terminal.__
|
||||
**Print full output of recorded asciicast to a terminal.**
|
||||
|
||||
While `asciinema play <filename>` replays the recorded session using timing
|
||||
information saved in the asciicast, `asciinema cat <filename>` dumps the full
|
||||
@@ -243,18 +311,17 @@ output (including all escape sequences) to a terminal immediately.
|
||||
|
||||
### `upload <filename>`
|
||||
|
||||
__Upload recorded asciicast to asciinema.org site.__
|
||||
**Upload recorded asciicast to asciinema.org site.**
|
||||
|
||||
This command uploads given asciicast (recorded by `rec` command) to
|
||||
asciinema.org, where it can be watched and shared.
|
||||
|
||||
`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload
|
||||
demo.cast` is a nice combo if you want to review an asciicast before
|
||||
`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload demo.cast` is a nice combo if you want to review an asciicast before
|
||||
publishing it on asciinema.org.
|
||||
|
||||
### `auth`
|
||||
|
||||
__Link your install ID with your asciinema.org user account.__
|
||||
**Link your install ID with your asciinema.org user account.**
|
||||
|
||||
If you want to manage your recordings (change title/theme, delete) at
|
||||
asciinema.org you need to link your "install ID" with asciinema.org user
|
||||
@@ -402,4 +469,5 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
|
||||
|
||||
Copyright © 2011–2021 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.
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import sys
|
||||
|
||||
__author__ = 'Marcin Kulik'
|
||||
__version__ = '2.1.0'
|
||||
__author__ = "Marcin Kulik"
|
||||
__version__ = "2.2.0"
|
||||
|
||||
if sys.version_info[0] < 3:
|
||||
raise ImportError('Python < 3 is unsupported.')
|
||||
if sys.version_info < (3, 6):
|
||||
raise ImportError("Python < 3.6 is unsupported.")
|
||||
|
||||
import asciinema.recorder
|
||||
# pylint: disable=wrong-import-position
|
||||
from typing import Any, Optional
|
||||
|
||||
from .recorder import record
|
||||
|
||||
|
||||
def record_asciicast(path, command=None, append=False, idle_time_limit=None,
|
||||
rec_stdin=False, title=None, metadata=None,
|
||||
command_env=None, capture_env=None):
|
||||
asciinema.recorder.record(
|
||||
path,
|
||||
def record_asciicast( # pylint: disable=too-many-arguments
|
||||
path_: str,
|
||||
command: Any = None,
|
||||
append: bool = False,
|
||||
idle_time_limit: Optional[int] = None,
|
||||
record_stdin: bool = False,
|
||||
title: Optional[str] = None,
|
||||
command_env: Any = None,
|
||||
capture_env: Any = None,
|
||||
) -> None:
|
||||
record(
|
||||
path_,
|
||||
command=command,
|
||||
append=append,
|
||||
idle_time_limit=idle_time_limit,
|
||||
rec_stdin=rec_stdin,
|
||||
record_stdin=record_stdin,
|
||||
title=title,
|
||||
metadata=metadata,
|
||||
command_env=command_env,
|
||||
capture_env=capture_env
|
||||
capture_env=capture_env,
|
||||
)
|
||||
|
||||
@@ -1,40 +1,56 @@
|
||||
import locale
|
||||
import argparse
|
||||
import locale
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from asciinema import __version__
|
||||
import asciinema.config as config
|
||||
from asciinema.commands.auth import AuthCommand
|
||||
from asciinema.commands.record import RecordCommand
|
||||
from asciinema.commands.play import PlayCommand
|
||||
from asciinema.commands.cat import CatCommand
|
||||
from asciinema.commands.upload import UploadCommand
|
||||
from . import __version__, config
|
||||
from .commands.auth import AuthCommand
|
||||
from .commands.cat import CatCommand
|
||||
from .commands.play import PlayCommand
|
||||
from .commands.record import RecordCommand
|
||||
from .commands.upload import UploadCommand
|
||||
|
||||
|
||||
def positive_float(value):
|
||||
value = float(value)
|
||||
if value <= 0.0:
|
||||
def positive_int(value: str) -> int:
|
||||
_value = int(value)
|
||||
if _value <= 0:
|
||||
raise argparse.ArgumentTypeError("must be positive")
|
||||
|
||||
return value
|
||||
return _value
|
||||
|
||||
|
||||
def maybe_str(v):
|
||||
def positive_float(value: str) -> float:
|
||||
_value = float(value)
|
||||
if _value <= 0.0:
|
||||
raise argparse.ArgumentTypeError("must be positive")
|
||||
|
||||
return _value
|
||||
|
||||
|
||||
def maybe_str(v: Any) -> Optional[str]:
|
||||
if v is not None:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if locale.nl_langinfo(locale.CODESET).upper() not in ['US-ASCII', 'UTF-8', 'UTF8']:
|
||||
print("asciinema needs an ASCII or UTF-8 character encoding to run. Check the output of `locale` command.")
|
||||
sys.exit(1)
|
||||
def main() -> Any:
|
||||
if locale.nl_langinfo(locale.CODESET).upper() not in [
|
||||
"US-ASCII",
|
||||
"UTF-8",
|
||||
"UTF8",
|
||||
]:
|
||||
sys.stderr.write(
|
||||
"asciinema needs an ASCII or UTF-8 character encoding to run. "
|
||||
"Check the output of `locale` command.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
cfg = config.load()
|
||||
except config.ConfigError as e:
|
||||
sys.stderr.write(str(e) + '\n')
|
||||
sys.exit(1)
|
||||
sys.stderr.write(f"{e}\n")
|
||||
return 1
|
||||
|
||||
# create the top-level parser
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -56,60 +72,152 @@ def main():
|
||||
\x1b[1masciinema cat demo.cast\x1b[0m
|
||||
|
||||
For help on a specific command run:
|
||||
\x1b[1masciinema <command> -h\x1b[0m""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
\x1b[1masciinema <command> -h\x1b[0m""", # noqa: E501
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"asciinema {__version__}"
|
||||
)
|
||||
parser.add_argument('--version', action='version', version='asciinema %s' % __version__)
|
||||
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
# create the parser for the "rec" command
|
||||
parser_rec = subparsers.add_parser('rec', help='Record terminal session')
|
||||
parser_rec.add_argument('--stdin', help='enable stdin recording, disabled by default', action='store_true', default=cfg.record_stdin)
|
||||
parser_rec.add_argument('--append', help='append to existing recording', action='store_true', default=False)
|
||||
parser_rec.add_argument('--raw', help='save only raw stdout output', action='store_true', default=False)
|
||||
parser_rec.add_argument('--overwrite', help='overwrite the file if it already exists', action='store_true', default=False)
|
||||
parser_rec.add_argument('-c', '--command', help='command to record, defaults to $SHELL', default=cfg.record_command)
|
||||
parser_rec.add_argument('-e', '--env', help='list of environment variables to capture, defaults to ' + config.DEFAULT_RECORD_ENV, default=cfg.record_env)
|
||||
parser_rec.add_argument('-t', '--title', help='title of the asciicast')
|
||||
parser_rec.add_argument('-i', '--idle-time-limit', help='limit recorded idle time to given number of seconds', type=positive_float, default=maybe_str(cfg.record_idle_time_limit))
|
||||
parser_rec.add_argument('-y', '--yes', help='answer "yes" to all prompts (e.g. upload confirmation)', action='store_true', default=cfg.record_yes)
|
||||
parser_rec.add_argument('-q', '--quiet', help='be quiet, suppress all notices/warnings (implies -y)', action='store_true', default=cfg.record_quiet)
|
||||
parser_rec.add_argument('filename', nargs='?', default='', help='filename/path to save the recording to')
|
||||
# create the parser for the `rec` command
|
||||
parser_rec = subparsers.add_parser("rec", help="Record terminal session")
|
||||
parser_rec.add_argument(
|
||||
"--stdin",
|
||||
help="enable stdin recording, disabled by default",
|
||||
action="store_true",
|
||||
default=cfg.record_stdin,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"--append",
|
||||
help="append to existing recording",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"--raw",
|
||||
help="save only raw stdout output",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"--overwrite",
|
||||
help="overwrite the file if it already exists",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"-c",
|
||||
"--command",
|
||||
help="command to record, defaults to $SHELL",
|
||||
default=cfg.record_command,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"-e",
|
||||
"--env",
|
||||
help="list of environment variables to capture, defaults to "
|
||||
+ config.DEFAULT_RECORD_ENV,
|
||||
default=cfg.record_env,
|
||||
)
|
||||
parser_rec.add_argument("-t", "--title", help="title of the asciicast")
|
||||
parser_rec.add_argument(
|
||||
"-i",
|
||||
"--idle-time-limit",
|
||||
help="limit recorded idle time to given number of seconds",
|
||||
type=positive_float,
|
||||
default=maybe_str(cfg.record_idle_time_limit),
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"--cols",
|
||||
help="override terminal columns for recorded process",
|
||||
type=positive_int,
|
||||
default=None,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"--rows",
|
||||
help="override terminal rows for recorded process",
|
||||
type=positive_int,
|
||||
default=None,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"-y",
|
||||
"--yes",
|
||||
help='answer "yes" to all prompts (e.g. upload confirmation)',
|
||||
action="store_true",
|
||||
default=cfg.record_yes,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
help="be quiet, suppress all notices/warnings (implies -y)",
|
||||
action="store_true",
|
||||
default=cfg.record_quiet,
|
||||
)
|
||||
parser_rec.add_argument(
|
||||
"filename",
|
||||
nargs="?",
|
||||
default="",
|
||||
help="filename/path to save the recording to",
|
||||
)
|
||||
parser_rec.set_defaults(cmd=RecordCommand)
|
||||
|
||||
# create the parser for the "play" command
|
||||
parser_play = subparsers.add_parser('play', help='Replay terminal session')
|
||||
parser_play.add_argument('-i', '--idle-time-limit', help='limit idle time during playback to given number of seconds', type=positive_float, default=maybe_str(cfg.play_idle_time_limit))
|
||||
parser_play.add_argument('-s', '--speed', help='playback speedup (can be fractional)', type=positive_float, default=cfg.play_speed)
|
||||
parser_play.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)')
|
||||
# create the parser for the `play` command
|
||||
parser_play = subparsers.add_parser("play", help="Replay terminal session")
|
||||
parser_play.add_argument(
|
||||
"-i",
|
||||
"--idle-time-limit",
|
||||
help="limit idle time during playback to given number of seconds",
|
||||
type=positive_float,
|
||||
default=maybe_str(cfg.play_idle_time_limit),
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"-s",
|
||||
"--speed",
|
||||
help="playback speedup (can be fractional)",
|
||||
type=positive_float,
|
||||
default=cfg.play_speed,
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"filename", help='local path, http/ipfs URL or "-" (read from stdin)'
|
||||
)
|
||||
parser_play.set_defaults(cmd=PlayCommand)
|
||||
|
||||
# create the parser for the "cat" command
|
||||
parser_cat = subparsers.add_parser('cat', help='Print full output of terminal session')
|
||||
parser_cat.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)')
|
||||
# create the parser for the `cat` command
|
||||
parser_cat = subparsers.add_parser(
|
||||
"cat", help="Print full output of terminal session"
|
||||
)
|
||||
parser_cat.add_argument(
|
||||
"filename", help='local path, http/ipfs URL or "-" (read from stdin)'
|
||||
)
|
||||
parser_cat.set_defaults(cmd=CatCommand)
|
||||
|
||||
# create the parser for the "upload" command
|
||||
parser_upload = subparsers.add_parser('upload', help='Upload locally saved terminal session to asciinema.org')
|
||||
parser_upload.add_argument('filename', help='filename or path of local recording')
|
||||
# create the parser for the `upload` command
|
||||
parser_upload = subparsers.add_parser(
|
||||
"upload", help="Upload locally saved terminal session to asciinema.org"
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"filename", help="filename or path of local recording"
|
||||
)
|
||||
parser_upload.set_defaults(cmd=UploadCommand)
|
||||
|
||||
# create the parser for the "auth" command
|
||||
parser_auth = subparsers.add_parser('auth', help='Manage recordings on asciinema.org account')
|
||||
# create the parser for the `auth` command
|
||||
parser_auth = subparsers.add_parser(
|
||||
"auth", help="Manage recordings on asciinema.org account"
|
||||
)
|
||||
parser_auth.set_defaults(cmd=AuthCommand)
|
||||
|
||||
# parse the args and call whatever function was selected
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'cmd'):
|
||||
if hasattr(args, "cmd"):
|
||||
command = args.cmd(args, cfg, os.environ)
|
||||
code = command.execute()
|
||||
sys.exit(code)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
return code
|
||||
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import json
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from asciinema import __version__
|
||||
from asciinema.urllib_http_adapter import URLLibHttpAdapter
|
||||
from asciinema.http_adapter import HTTPConnectionError
|
||||
from . import __version__
|
||||
from .http_adapter import HTTPConnectionError
|
||||
from .urllib_http_adapter import URLLibHttpAdapter
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
@@ -13,73 +14,88 @@ class APIError(Exception):
|
||||
|
||||
|
||||
class Api:
|
||||
|
||||
def __init__(self, url, user, install_id, http_adapter=None):
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
user: Optional[str],
|
||||
install_id: str,
|
||||
http_adapter: Any = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.user = user
|
||||
self.install_id = install_id
|
||||
self.http_adapter = http_adapter if http_adapter is not None else URLLibHttpAdapter()
|
||||
self.http_adapter = (
|
||||
http_adapter if http_adapter is not None else URLLibHttpAdapter()
|
||||
)
|
||||
|
||||
def hostname(self):
|
||||
def hostname(self) -> Optional[str]:
|
||||
return urlparse(self.url).hostname
|
||||
|
||||
def auth_url(self):
|
||||
return "{}/connect/{}".format(self.url, self.install_id)
|
||||
def auth_url(self) -> str:
|
||||
return f"{self.url}/connect/{self.install_id}"
|
||||
|
||||
def upload_url(self):
|
||||
return "{}/api/asciicasts".format(self.url)
|
||||
def upload_url(self) -> str:
|
||||
return f"{self.url}/api/asciicasts"
|
||||
|
||||
def upload_asciicast(self, path):
|
||||
with open(path, 'rb') as f:
|
||||
def upload_asciicast(self, path_: str) -> Tuple[Any, Any]:
|
||||
with open(path_, "rb") as f:
|
||||
try:
|
||||
status, headers, body = self.http_adapter.post(
|
||||
self.upload_url(),
|
||||
files={"asciicast": ("ascii.cast", f)},
|
||||
headers=self._headers(),
|
||||
username=self.user,
|
||||
password=self.install_id
|
||||
password=self.install_id,
|
||||
)
|
||||
except HTTPConnectionError as e:
|
||||
raise APIError(str(e))
|
||||
raise APIError(str(e)) from e
|
||||
|
||||
if status != 200 and status != 201:
|
||||
if status not in (200, 201):
|
||||
self._handle_error(status, body)
|
||||
|
||||
if (headers.get('content-type') or '')[0:16] == 'application/json':
|
||||
if (headers.get("content-type") or "")[0:16] == "application/json":
|
||||
result = json.loads(body)
|
||||
else:
|
||||
result = {'url': body}
|
||||
result = {"url": body}
|
||||
|
||||
return result, headers.get('Warning')
|
||||
return result, headers.get("Warning")
|
||||
|
||||
def _headers(self):
|
||||
return {'User-Agent': self._user_agent(), 'Accept': 'application/json'}
|
||||
def _headers(self) -> Dict[str, Union[Callable[[], str], str]]:
|
||||
return {"user-agent": self._user_agent(), "accept": "application/json"}
|
||||
|
||||
def _user_agent(self):
|
||||
os = re.sub('([^-]+)-(.*)', '\\1/\\2', platform.platform())
|
||||
@staticmethod
|
||||
def _user_agent() -> str:
|
||||
os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform())
|
||||
|
||||
return 'asciinema/%s %s/%s %s' % (__version__,
|
||||
platform.python_implementation(),
|
||||
platform.python_version(),
|
||||
os
|
||||
)
|
||||
return (
|
||||
f"asciinema/{__version__} {platform.python_implementation()}"
|
||||
f"/{platform.python_version()} {os}"
|
||||
)
|
||||
|
||||
def _handle_error(self, status, body):
|
||||
@staticmethod
|
||||
def _handle_error(status: int, body: bytes) -> None:
|
||||
errors = {
|
||||
400: "Invalid request: %s" % body,
|
||||
400: f"Invalid request: {body.decode('utf-8', 'replace')}",
|
||||
401: "Invalid or revoked install ID",
|
||||
404: "API endpoint not found. This asciinema version may no longer be supported. Please upgrade to the latest version.",
|
||||
404: (
|
||||
"API endpoint not found. "
|
||||
"This asciinema version may no longer be supported. "
|
||||
"Please upgrade to the latest version."
|
||||
),
|
||||
413: "Sorry, your asciicast is too big.",
|
||||
422: "Invalid asciicast: %s" % body,
|
||||
503: "The server is down for maintenance. Try again in a minute."
|
||||
422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
|
||||
503: "The server is down for maintenance. Try again in a minute.",
|
||||
}
|
||||
|
||||
error = errors.get(status)
|
||||
|
||||
if not error:
|
||||
if status >= 500:
|
||||
error = "The server is having temporary problems. Try again in a minute."
|
||||
error = (
|
||||
"The server is having temporary problems. "
|
||||
"Try again in a minute."
|
||||
)
|
||||
else:
|
||||
error = "HTTP status: %i" % status
|
||||
error = f"HTTP status: {status}"
|
||||
|
||||
raise APIError(error)
|
||||
|
||||
@@ -1,92 +1,116 @@
|
||||
import sys
|
||||
import os
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
import urllib.error
|
||||
import html.parser
|
||||
import gzip
|
||||
import codecs
|
||||
import gzip
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
from codecs import StreamReader
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, List, TextIO, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import v1
|
||||
from . import v2
|
||||
from . import v1, v2
|
||||
|
||||
|
||||
class LoadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Parser(html.parser.HTMLParser):
|
||||
def __init__(self):
|
||||
html.parser.HTMLParser.__init__(self)
|
||||
class Parser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
HTMLParser.__init__(self)
|
||||
self.url = None
|
||||
|
||||
def handle_starttag(self, tag, attrs_list):
|
||||
# look for <link rel="alternate" type="application/x-asciicast" href="https://...cast">
|
||||
if tag == 'link':
|
||||
attrs = {}
|
||||
for k, v in attrs_list:
|
||||
attrs[k] = v
|
||||
def error(self, message: str) -> None:
|
||||
raise NotImplementedError(
|
||||
"subclasses of ParserBase must override error()"
|
||||
", but HTMLParser does not"
|
||||
)
|
||||
|
||||
if attrs.get('rel') == 'alternate':
|
||||
type = attrs.get('type')
|
||||
if type == 'application/asciicast+json' or type == 'application/x-asciicast':
|
||||
self.url = attrs.get('href')
|
||||
def handle_starttag(self, tag: str, attrs: List[Any]) -> None:
|
||||
# look for <link rel="alternate"
|
||||
# type="application/x-asciicast"
|
||||
# href="https://...cast">
|
||||
if tag == "link":
|
||||
# avoid modifying function signature keyword args from base class
|
||||
_attrs = {}
|
||||
for k, v in attrs:
|
||||
_attrs[k] = v
|
||||
|
||||
if _attrs.get("rel") == "alternate":
|
||||
type_ = _attrs.get("type")
|
||||
if type_ in (
|
||||
"application/asciicast+json",
|
||||
"application/x-asciicast",
|
||||
):
|
||||
self.url = _attrs.get("href")
|
||||
|
||||
|
||||
def open_url(url):
|
||||
def open_url(url: str) -> Union[StreamReader, TextIO]:
|
||||
if url == "-":
|
||||
return sys.stdin
|
||||
|
||||
if url.startswith("ipfs://"):
|
||||
url = "https://ipfs.io/ipfs/%s" % url[7:]
|
||||
url = f"https://ipfs.io/ipfs/{url[7:]}"
|
||||
elif url.startswith("dweb:/ipfs/"):
|
||||
url = "https://ipfs.io/%s" % url[5:]
|
||||
url = f"https://ipfs.io/{url[5:]}"
|
||||
|
||||
if url.startswith("http:") or url.startswith("https:"):
|
||||
req = Request(url)
|
||||
req.add_header('Accept-Encoding', 'gzip')
|
||||
response = urlopen(req)
|
||||
body = response
|
||||
url = response.geturl() # final URL after redirects
|
||||
req.add_header("Accept-Encoding", "gzip")
|
||||
with urlopen(req) as response:
|
||||
body = response
|
||||
url = response.geturl() # final URL after redirects
|
||||
|
||||
if response.headers['Content-Encoding'] == 'gzip':
|
||||
body = gzip.open(body)
|
||||
if response.headers["Content-Encoding"] == "gzip":
|
||||
body = gzip.open(body)
|
||||
|
||||
utf8_reader = codecs.getreader('utf-8')
|
||||
content_type = response.headers['Content-Type']
|
||||
utf8_reader = codecs.getreader("utf-8")
|
||||
content_type = response.headers["Content-Type"]
|
||||
|
||||
if content_type and content_type.startswith('text/html'):
|
||||
html = utf8_reader(body, errors='replace').read()
|
||||
parser = Parser()
|
||||
parser.feed(html)
|
||||
new_url = parser.url
|
||||
if content_type and content_type.startswith("text/html"):
|
||||
html = utf8_reader(body, errors="replace").read()
|
||||
parser = Parser()
|
||||
parser.feed(html)
|
||||
new_url = parser.url
|
||||
|
||||
if not new_url:
|
||||
raise LoadError("""<link rel="alternate" type="application/x-asciicast" href="..."> not found in fetched HTML document""")
|
||||
if not new_url:
|
||||
raise LoadError(
|
||||
'<link rel="alternate" '
|
||||
'type="application/x-asciicast" '
|
||||
'href="..."> '
|
||||
"not found in fetched HTML document"
|
||||
)
|
||||
|
||||
if "://" not in new_url:
|
||||
base_url = urlparse(url)
|
||||
if "://" not in new_url:
|
||||
base_url = urlparse(url)
|
||||
|
||||
if new_url.startswith("/"):
|
||||
new_url = urlunparse((base_url[0], base_url[1], new_url, '', '', ''))
|
||||
else:
|
||||
path = os.path.dirname(base_url[2]) + '/' + new_url
|
||||
new_url = urlunparse((base_url[0], base_url[1], path, '', '', ''))
|
||||
if new_url.startswith("/"):
|
||||
new_url = urlunparse(
|
||||
(base_url[0], base_url[1], new_url, "", "", "")
|
||||
)
|
||||
else:
|
||||
path = f"{os.path.dirname(base_url[2])}/{new_url}"
|
||||
new_url = urlunparse(
|
||||
(base_url[0], base_url[1], path, "", "", "")
|
||||
)
|
||||
|
||||
return open_url(new_url)
|
||||
return open_url(new_url)
|
||||
|
||||
return utf8_reader(body, errors='strict')
|
||||
return utf8_reader(body, errors="strict")
|
||||
|
||||
return open(url, mode='rt', encoding='utf-8')
|
||||
return open(url, mode="rt", encoding="utf-8")
|
||||
|
||||
|
||||
class open_from_url():
|
||||
class open_from_url:
|
||||
FORMAT_ERROR = "only asciicast v1 and v2 formats can be opened"
|
||||
|
||||
def __init__(self, url):
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
self.file: Union[StreamReader, TextIO, None] = None
|
||||
self.context: Any = None
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> Any:
|
||||
try:
|
||||
self.file = open_url(self.url)
|
||||
first_line = self.file.readline()
|
||||
@@ -98,11 +122,13 @@ class open_from_url():
|
||||
try: # try v1 next
|
||||
self.context = v1.open_from_file(first_line, self.file)
|
||||
return self.context.__enter__()
|
||||
except v1.LoadError:
|
||||
raise LoadError(self.FORMAT_ERROR)
|
||||
except v1.LoadError as e:
|
||||
raise LoadError(self.FORMAT_ERROR) from e
|
||||
|
||||
except (OSError, urllib.error.HTTPError) as e:
|
||||
raise LoadError(str(e))
|
||||
raise LoadError(str(e)) from e
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
def __exit__(
|
||||
self, exc_type: str, exc_value: str, exc_traceback: str
|
||||
) -> None:
|
||||
self.context.__exit__(exc_type, exc_value, exc_traceback)
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
def to_relative_time(events):
|
||||
from typing import Any, Generator, List, Optional
|
||||
|
||||
|
||||
def to_relative_time(
|
||||
events: Generator[List[Any], None, None]
|
||||
) -> Generator[List[Any], None, None]:
|
||||
prev_time = 0
|
||||
|
||||
for frame in events:
|
||||
time, type, data = frame
|
||||
time, type_, data = frame
|
||||
delay = time - prev_time
|
||||
prev_time = time
|
||||
yield [delay, type, data]
|
||||
yield [delay, type_, data]
|
||||
|
||||
|
||||
def to_absolute_time(events):
|
||||
def to_absolute_time(
|
||||
events: Generator[List[Any], None, None]
|
||||
) -> Generator[List[Any], None, None]:
|
||||
time = 0
|
||||
|
||||
for frame in events:
|
||||
delay, type, data = frame
|
||||
delay, type_, data = frame
|
||||
time = time + delay
|
||||
yield [time, type, data]
|
||||
yield [time, type_, data]
|
||||
|
||||
|
||||
def cap_relative_time(events, time_limit):
|
||||
def cap_relative_time(
|
||||
events: Generator[List[Any], None, None], time_limit: Optional[float]
|
||||
) -> Generator[List[Any], None, None]:
|
||||
if time_limit:
|
||||
return ([min(delay, time_limit), type, data] for delay, type, data in events)
|
||||
else:
|
||||
return events
|
||||
return (
|
||||
[min(delay, time_limit), type_, data]
|
||||
for delay, type_, data in events
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def adjust_speed(events, speed):
|
||||
return ([delay / speed, type, data] for delay, type, data in events)
|
||||
def adjust_speed(
|
||||
events: Generator[List[Any], None, None], speed: Any
|
||||
) -> Generator[List[Any], None, None]:
|
||||
return ([delay / speed, type_, data] for delay, type_, data in events)
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
from os import path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..file_writer import file_writer
|
||||
|
||||
|
||||
class writer():
|
||||
class writer(file_writer):
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
path_: str,
|
||||
metadata: Any = None,
|
||||
append: bool = False,
|
||||
buffering: int = 0,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
super().__init__(path_, on_error)
|
||||
|
||||
def __init__(self, path, metadata=None, append=False, buffering=0):
|
||||
if append and os.path.exists(path) and os.stat(path).st_size == 0: # true for pipes
|
||||
if (
|
||||
append and path.exists(path_) and os.stat(path_).st_size == 0
|
||||
): # true for pipes
|
||||
append = False
|
||||
|
||||
self.path = path
|
||||
self.buffering = buffering
|
||||
self.mode = 'ab' if append else 'wb'
|
||||
self.mode: str = "ab" if append else "wb"
|
||||
self.metadata = metadata
|
||||
|
||||
def __enter__(self):
|
||||
self.file = open(self.path, mode=self.mode, buffering=self.buffering)
|
||||
return self
|
||||
def write_stdout(self, _ts: float, data: Any) -> None:
|
||||
self._write(data)
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
self.file.close()
|
||||
|
||||
def write_stdout(self, ts, data):
|
||||
self.file.write(data)
|
||||
|
||||
def write_stdin(self, ts, data):
|
||||
# pylint: disable=no-self-use
|
||||
def write_stdin(self, ts: float, data: Any) -> None:
|
||||
pass
|
||||
|
||||
# pylint: disable=consider-using-with
|
||||
def _open_file(self) -> None:
|
||||
if self.path == "-":
|
||||
self.file = os.fdopen(
|
||||
sys.stdout.fileno(),
|
||||
mode=self.mode,
|
||||
buffering=self.buffering,
|
||||
closefd=False,
|
||||
)
|
||||
else:
|
||||
self.file = open(
|
||||
self.path, mode=self.mode, buffering=self.buffering
|
||||
)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import json
|
||||
import json.decoder
|
||||
from codecs import StreamReader
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Any, Dict, Generator, List, Optional, TextIO, Union
|
||||
|
||||
from asciinema.asciicast.events import to_absolute_time
|
||||
|
||||
|
||||
try:
|
||||
JSONDecodeError = json.decoder.JSONDecodeError
|
||||
except AttributeError:
|
||||
JSONDecodeError = ValueError
|
||||
from .events import to_absolute_time
|
||||
|
||||
|
||||
class LoadError(Exception):
|
||||
@@ -15,46 +11,52 @@ class LoadError(Exception):
|
||||
|
||||
|
||||
class Asciicast:
|
||||
|
||||
def __init__(self, attrs):
|
||||
self.version = 1
|
||||
def __init__(self, attrs: Dict[str, Any]) -> None:
|
||||
self.version: int = 1
|
||||
self.__attrs = attrs
|
||||
self.idle_time_limit = None # v1 doesn't store it
|
||||
|
||||
@property
|
||||
def v2_header(self):
|
||||
keys = ['width', 'height', 'duration', 'command', 'title', 'env']
|
||||
header = {k: v for k, v in self.__attrs.items() if k in keys and v is not None}
|
||||
def v2_header(self) -> Dict[str, Any]:
|
||||
keys = ["width", "height", "duration", "command", "title", "env"]
|
||||
header = {
|
||||
k: v
|
||||
for k, v in self.__attrs.items()
|
||||
if k in keys and v is not None
|
||||
}
|
||||
return header
|
||||
|
||||
def __stdout_events(self):
|
||||
for time, data in self.__attrs['stdout']:
|
||||
yield [time, 'o', data]
|
||||
def __stdout_events(self) -> Generator[List[Any], None, None]:
|
||||
for time, data in self.__attrs["stdout"]:
|
||||
yield [time, "o", data]
|
||||
|
||||
def events(self):
|
||||
def events(self) -> Any:
|
||||
return self.stdout_events()
|
||||
|
||||
def stdout_events(self):
|
||||
def stdout_events(self) -> Generator[List[Any], None, None]:
|
||||
return to_absolute_time(self.__stdout_events())
|
||||
|
||||
|
||||
class open_from_file():
|
||||
FORMAT_ERROR = "only asciicast v1 format can be opened"
|
||||
class open_from_file:
|
||||
FORMAT_ERROR: str = "only asciicast v1 format can be opened"
|
||||
|
||||
def __init__(self, first_line, file):
|
||||
def __init__(
|
||||
self, first_line: str, file: Union[TextIO, StreamReader]
|
||||
) -> None:
|
||||
self.first_line = first_line
|
||||
self.file = file
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> Optional[Asciicast]:
|
||||
try:
|
||||
attrs = json.loads(self.first_line + self.file.read())
|
||||
|
||||
if attrs.get('version') == 1:
|
||||
if attrs.get("version") == 1:
|
||||
return Asciicast(attrs)
|
||||
else:
|
||||
raise LoadError(self.FORMAT_ERROR)
|
||||
except JSONDecodeError as e:
|
||||
raise LoadError(self.FORMAT_ERROR)
|
||||
except JSONDecodeError as e:
|
||||
raise LoadError(self.FORMAT_ERROR) from e
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
def __exit__(
|
||||
self, exc_type: str, exc_value: str, exc_traceback: str
|
||||
) -> None:
|
||||
self.file.close()
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import json
|
||||
import json.decoder
|
||||
import time
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from codecs import StreamReader
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Generator,
|
||||
List,
|
||||
Optional,
|
||||
TextIO,
|
||||
Union,
|
||||
)
|
||||
|
||||
try:
|
||||
JSONDecodeError = json.decoder.JSONDecodeError
|
||||
except AttributeError:
|
||||
JSONDecodeError = ValueError
|
||||
from ..file_writer import file_writer
|
||||
|
||||
|
||||
class LoadError(Exception):
|
||||
@@ -14,113 +23,149 @@ class LoadError(Exception):
|
||||
|
||||
|
||||
class Asciicast:
|
||||
|
||||
def __init__(self, f, header):
|
||||
self.version = 2
|
||||
def __init__(
|
||||
self, f: Union[TextIO, StreamReader], header: Dict[str, Any]
|
||||
) -> None:
|
||||
self.version: int = 2
|
||||
self.__file = f
|
||||
self.v2_header = header
|
||||
self.idle_time_limit = header.get('idle_time_limit')
|
||||
self.idle_time_limit = header.get("idle_time_limit")
|
||||
|
||||
def events(self):
|
||||
def events(self) -> Generator[Any, None, None]:
|
||||
for line in self.__file:
|
||||
yield json.loads(line)
|
||||
|
||||
def stdout_events(self):
|
||||
for time, type, data in self.events():
|
||||
if type == 'o':
|
||||
yield [time, type, data]
|
||||
def stdout_events(self) -> Generator[List[Any], None, None]:
|
||||
for time, type_, data in self.events():
|
||||
if type_ == "o":
|
||||
yield [time, type_, data]
|
||||
|
||||
|
||||
def build_from_header_and_file(header, f):
|
||||
def build_from_header_and_file(
|
||||
header: Dict[str, Any], f: Union[StreamReader, TextIO]
|
||||
) -> Asciicast:
|
||||
return Asciicast(f, header)
|
||||
|
||||
|
||||
class open_from_file():
|
||||
class open_from_file:
|
||||
FORMAT_ERROR = "only asciicast v2 format can be opened"
|
||||
|
||||
def __init__(self, first_line, file):
|
||||
def __init__(
|
||||
self, first_line: str, file: Union[StreamReader, TextIO]
|
||||
) -> None:
|
||||
self.first_line = first_line
|
||||
self.file = file
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> Asciicast:
|
||||
try:
|
||||
v2_header = json.loads(self.first_line)
|
||||
if v2_header.get('version') == 2:
|
||||
v2_header: Dict[str, Any] = json.loads(self.first_line)
|
||||
if v2_header.get("version") == 2:
|
||||
return build_from_header_and_file(v2_header, self.file)
|
||||
else:
|
||||
raise LoadError(self.FORMAT_ERROR)
|
||||
except JSONDecodeError as e:
|
||||
raise LoadError(self.FORMAT_ERROR)
|
||||
except JSONDecodeError as e:
|
||||
raise LoadError(self.FORMAT_ERROR) from e
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
def __exit__(
|
||||
self, exc_type: str, exc_value: str, exc_traceback: str
|
||||
) -> None:
|
||||
self.file.close()
|
||||
|
||||
|
||||
def get_duration(path):
|
||||
with open(path, mode='rt', encoding='utf-8') as f:
|
||||
def get_duration(path_: str) -> Any:
|
||||
with open(path_, mode="rt", encoding="utf-8") as f:
|
||||
first_line = f.readline()
|
||||
with open_from_file(first_line, f) as a:
|
||||
last_frame = None
|
||||
for last_frame in a.stdout_events():
|
||||
pass
|
||||
return last_frame[0]
|
||||
|
||||
|
||||
def build_header(width, height, metadata):
|
||||
header = {'version': 2, 'width': width, 'height': height}
|
||||
def build_header(
|
||||
width: Optional[int], height: Optional[int], metadata: Any
|
||||
) -> Dict[str, Any]:
|
||||
header = {"version": 2, "width": width, "height": height}
|
||||
header.update(metadata)
|
||||
|
||||
assert 'width' in header, 'width missing in metadata'
|
||||
assert 'height' in header, 'height missing in metadata'
|
||||
assert type(header['width']) == int
|
||||
assert type(header['height']) == int
|
||||
assert "width" in header, "width missing in metadata"
|
||||
assert "height" in header, "height missing in metadata"
|
||||
assert isinstance(header["width"], int)
|
||||
assert isinstance(header["height"], int)
|
||||
|
||||
if 'timestamp' in header:
|
||||
assert type(header['timestamp']) == int or type(header['timestamp']) == float
|
||||
if "timestamp" in header:
|
||||
assert isinstance(header["timestamp"], (int, float))
|
||||
|
||||
return header
|
||||
|
||||
|
||||
class writer():
|
||||
class writer(file_writer):
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
path_: str,
|
||||
metadata: Any = None,
|
||||
append: bool = False,
|
||||
buffering: int = 1,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
super().__init__(path_, on_error)
|
||||
|
||||
def __init__(self, path, metadata=None, append=False, buffering=1, width=None, height=None):
|
||||
self.path = path
|
||||
self.buffering = buffering
|
||||
self.stdin_decoder = codecs.getincrementaldecoder('UTF-8')('replace')
|
||||
self.stdout_decoder = codecs.getincrementaldecoder('UTF-8')('replace')
|
||||
self.stdin_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
|
||||
self.stdout_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
|
||||
|
||||
if append:
|
||||
self.mode = 'a'
|
||||
self.mode = "a"
|
||||
self.header = None
|
||||
else:
|
||||
self.mode = 'w'
|
||||
self.mode = "w"
|
||||
self.header = build_header(width, height, metadata or {})
|
||||
|
||||
def __enter__(self):
|
||||
self.file = open(self.path, mode=self.mode, buffering=self.buffering)
|
||||
def __enter__(self) -> Any:
|
||||
self._open_file()
|
||||
|
||||
if self.header:
|
||||
self.__write_line(self.header)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
self.file.close()
|
||||
|
||||
def write_stdout(self, ts, data):
|
||||
if type(data) == str:
|
||||
data = data.encode(encoding='utf-8', errors='strict')
|
||||
def write_stdout(self, ts: float, data: Union[str, bytes]) -> None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode(encoding="utf-8", errors="strict")
|
||||
data = self.stdout_decoder.decode(data)
|
||||
self.__write_event(ts, 'o', data)
|
||||
self.__write_event(ts, "o", data)
|
||||
|
||||
def write_stdin(self, ts, data):
|
||||
if type(data) == str:
|
||||
data = data.encode(encoding='utf-8', errors='strict')
|
||||
def write_stdin(self, ts: float, data: Union[str, bytes]) -> None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode(encoding="utf-8", errors="strict")
|
||||
data = self.stdin_decoder.decode(data)
|
||||
self.__write_event(ts, 'i', data)
|
||||
self.__write_event(ts, "i", data)
|
||||
|
||||
def __write_event(self, ts, etype, data):
|
||||
# pylint: disable=consider-using-with
|
||||
def _open_file(self) -> None:
|
||||
if self.path == "-":
|
||||
self.file = os.fdopen(
|
||||
sys.stdout.fileno(),
|
||||
mode=self.mode,
|
||||
buffering=self.buffering,
|
||||
encoding="utf-8",
|
||||
closefd=False,
|
||||
)
|
||||
else:
|
||||
self.file = open(
|
||||
self.path,
|
||||
mode=self.mode,
|
||||
buffering=self.buffering,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def __write_event(self, ts: float, etype: str, data: str) -> None:
|
||||
self.__write_line([round(ts, 6), etype, data])
|
||||
|
||||
def __write_line(self, obj):
|
||||
line = json.dumps(obj, ensure_ascii=False, indent=None, separators=(', ', ': '))
|
||||
self.file.write(line + '\n')
|
||||
def __write_line(self, obj: Any) -> None:
|
||||
line = json.dumps(
|
||||
obj, ensure_ascii=False, indent=None, separators=(", ", ": ")
|
||||
)
|
||||
|
||||
self._write(f"{line}\n")
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
# Importing synchronize is to detect platforms where
|
||||
# multiprocessing does not work (python issue 3770)
|
||||
# and cause an ImportError. Otherwise it will happen
|
||||
# later when trying to use Queue().
|
||||
from multiprocessing import synchronize, Process, Queue
|
||||
from multiprocessing import Process, Queue, synchronize
|
||||
|
||||
# pylint: disable=pointless-statement
|
||||
lambda _=synchronize: None # avoid pruning import
|
||||
except ImportError:
|
||||
from threading import Thread as Process
|
||||
from queue import Queue
|
||||
from queue import Queue # type: ignore
|
||||
from threading import Thread as Process # type: ignore
|
||||
|
||||
|
||||
class async_worker():
|
||||
class async_worker:
|
||||
def __init__(self) -> None:
|
||||
self.queue: Queue[Any] = Queue()
|
||||
self.process: Optional[Process] = None
|
||||
|
||||
def __init__(self):
|
||||
self.queue = Queue()
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> Any:
|
||||
self.process = Process(target=self.run)
|
||||
self.process.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
def __exit__(
|
||||
self, exc_type: str, exc_value: str, exc_traceback: str
|
||||
) -> None:
|
||||
self.queue.put(None)
|
||||
assert isinstance(self.process, Process)
|
||||
self.process.join()
|
||||
|
||||
def enqueue(self, payload):
|
||||
if self.process.exitcode != 0:
|
||||
raise RuntimeError(
|
||||
f"worker process exited with code {self.process.exitcode}"
|
||||
)
|
||||
|
||||
def enqueue(self, payload: Any) -> None:
|
||||
self.queue.put(payload)
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
payload: Any
|
||||
for payload in iter(self.queue.get, None):
|
||||
self.perform(payload)
|
||||
# pylint: disable=no-member
|
||||
self.perform(payload) # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
from asciinema.commands.command import Command
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..config import Config
|
||||
from .command import Command
|
||||
|
||||
|
||||
class AuthCommand(Command):
|
||||
|
||||
def __init__(self, args, config, env):
|
||||
def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
|
||||
Command.__init__(self, args, config, env)
|
||||
|
||||
def execute(self):
|
||||
self.print('Open the following URL in a web browser to link your '
|
||||
'install ID with your %s user account:\n\n'
|
||||
'%s\n\n'
|
||||
'This will associate all recordings uploaded from this machine '
|
||||
'(past and future ones) to your account, '
|
||||
'and allow you to manage them (change title/theme, delete) at %s.'
|
||||
% (self.api.hostname(), self.api.auth_url(), self.api.hostname()))
|
||||
def execute(self) -> None:
|
||||
self.print(
|
||||
f"Open the following URL in a web browser to link your install ID "
|
||||
f"with your {self.api.hostname()} user account:\n\n"
|
||||
f"{self.api.auth_url()}\n\n"
|
||||
"This will associate all recordings uploaded from this machine "
|
||||
"(past and future ones) to your account"
|
||||
", and allow you to manage them (change title/theme, delete) at "
|
||||
f"{self.api.hostname()}."
|
||||
)
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
|
||||
from asciinema.commands.command import Command
|
||||
from asciinema.term import raw
|
||||
import asciinema.asciicast as asciicast
|
||||
from .. import asciicast
|
||||
from ..config import Config
|
||||
from ..tty_ import raw
|
||||
from .command import Command
|
||||
|
||||
|
||||
class CatCommand(Command):
|
||||
|
||||
def __init__(self, args, config, env):
|
||||
def __init__(self, args: Any, config: Config, env: Dict[str, str]):
|
||||
Command.__init__(self, args, config, env)
|
||||
self.filename = args.filename
|
||||
|
||||
def execute(self):
|
||||
def execute(self) -> int:
|
||||
try:
|
||||
stdin = open('/dev/tty')
|
||||
with raw(stdin.fileno()):
|
||||
with asciicast.open_from_url(self.filename) as a:
|
||||
for t, _type, text in a.stdout_events():
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
with open("/dev/tty", "rt", encoding="utf-8") as stdin:
|
||||
with raw(stdin.fileno()):
|
||||
with asciicast.open_from_url(self.filename) as a:
|
||||
for _, _type, text in a.stdout_events():
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
except asciicast.LoadError as e:
|
||||
self.print_error("printing failed: %s" % str(e))
|
||||
self.print_error(f"printing failed: {str(e)}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from asciinema.api import Api
|
||||
from ..api import Api
|
||||
from ..config import Config
|
||||
|
||||
|
||||
class Command:
|
||||
|
||||
def __init__(self, args, config, env):
|
||||
self.quiet = False
|
||||
def __init__(self, _args: Any, config: Config, env: Dict[str, str]):
|
||||
self.quiet: bool = False
|
||||
self.api = Api(config.api_url, env.get("USER"), config.install_id)
|
||||
|
||||
def print(self, text, file=sys.stdout, end="\n", force=False):
|
||||
def print(
|
||||
self,
|
||||
text: str,
|
||||
end: str = "\n",
|
||||
color: Optional[int] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if not self.quiet or force:
|
||||
print(text, file=file, end=end)
|
||||
if color is not None and os.isatty(sys.stderr.fileno()):
|
||||
text = f"\x1b[0;3{color}m{text}\x1b[0m"
|
||||
|
||||
def print_info(self, text):
|
||||
self.print("\x1b[0;32masciinema: %s\x1b[0m" % text)
|
||||
print(text, file=sys.stderr, end=end)
|
||||
|
||||
def print_warning(self, text):
|
||||
self.print("\x1b[0;33masciinema: %s\x1b[0m" % text)
|
||||
def print_info(self, text: str) -> None:
|
||||
self.print(f"asciinema: {text}", color=2)
|
||||
|
||||
def print_error(self, text):
|
||||
self.print("\x1b[0;31masciinema: %s\x1b[0m" % text, file=sys.stderr, force=True)
|
||||
def print_warning(self, text: str) -> None:
|
||||
self.print(f"asciinema: {text}", color=3)
|
||||
|
||||
def print_error(self, text: str) -> None:
|
||||
self.print(f"asciinema: {text}", color=1, force=True)
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
from asciinema.commands.command import Command
|
||||
from asciinema.player import Player
|
||||
import asciinema.asciicast as asciicast
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .. import asciicast
|
||||
from ..commands.command import Command
|
||||
from ..config import Config
|
||||
from ..player import Player
|
||||
|
||||
|
||||
class PlayCommand(Command):
|
||||
|
||||
def __init__(self, args, config, env, player=None):
|
||||
def __init__(
|
||||
self,
|
||||
args: Any,
|
||||
config: Config,
|
||||
env: Dict[str, str],
|
||||
player: Optional[Player] = None,
|
||||
) -> None:
|
||||
Command.__init__(self, args, config, env)
|
||||
self.filename = args.filename
|
||||
self.idle_time_limit = args.idle_time_limit
|
||||
self.speed = args.speed
|
||||
self.player = player if player is not None else Player()
|
||||
self.key_bindings = {
|
||||
'pause': config.play_pause_key,
|
||||
'step': config.play_step_key
|
||||
"pause": config.play_pause_key,
|
||||
"step": config.play_step_key,
|
||||
}
|
||||
|
||||
def execute(self):
|
||||
def execute(self) -> int:
|
||||
try:
|
||||
with asciicast.open_from_url(self.filename) as a:
|
||||
self.player.play(a, self.idle_time_limit, self.speed, self.key_bindings)
|
||||
self.player.play(
|
||||
a, self.idle_time_limit, self.speed, self.key_bindings
|
||||
)
|
||||
|
||||
except asciicast.LoadError as e:
|
||||
self.print_error("playback failed: %s" % str(e))
|
||||
self.print_error(f"playback failed: {str(e)}")
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 1
|
||||
|
||||
@@ -1,53 +1,63 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import asciinema.recorder as recorder
|
||||
import asciinema.asciicast.raw as raw
|
||||
import asciinema.asciicast.v2 as v2
|
||||
import asciinema.notifier as notifier
|
||||
from asciinema.api import APIError
|
||||
from asciinema.commands.command import Command
|
||||
from .. import notifier, recorder
|
||||
from ..api import APIError
|
||||
from ..asciicast import raw, v2
|
||||
from ..commands.command import Command
|
||||
from ..config import Config
|
||||
|
||||
|
||||
class RecordCommand(Command):
|
||||
|
||||
def __init__(self, args, config, env):
|
||||
class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
|
||||
Command.__init__(self, args, config, env)
|
||||
self.quiet = args.quiet
|
||||
self.filename = args.filename
|
||||
self.rec_stdin = args.stdin
|
||||
self.record_stdin = args.stdin
|
||||
self.command = args.command
|
||||
self.env_whitelist = args.env
|
||||
self.title = args.title
|
||||
self.assume_yes = args.yes or args.quiet
|
||||
self.idle_time_limit = args.idle_time_limit
|
||||
self.cols_override = args.cols
|
||||
self.rows_override = args.rows
|
||||
self.append = args.append
|
||||
self.overwrite = args.overwrite
|
||||
self.raw = args.raw
|
||||
self.writer = raw.writer if args.raw else v2.writer
|
||||
self.notifier = notifier.get_notifier(config.notifications_enabled, config.notifications_command)
|
||||
self.notifier = notifier.get_notifier(
|
||||
config.notifications_enabled, config.notifications_command
|
||||
)
|
||||
self.env = env
|
||||
self.key_bindings = {
|
||||
'prefix': config.record_prefix_key,
|
||||
'pause': config.record_pause_key
|
||||
"prefix": config.record_prefix_key,
|
||||
"pause": config.record_pause_key,
|
||||
}
|
||||
|
||||
def execute(self):
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-return-statements
|
||||
# pylint: disable=too-many-statements
|
||||
def execute(self) -> int:
|
||||
upload = False
|
||||
append = self.append
|
||||
|
||||
if self.filename == "":
|
||||
if self.raw:
|
||||
self.print_error("filename required when recording in raw mode")
|
||||
self.print_error(
|
||||
"filename required when recording in raw mode"
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
self.filename = _tmp_path()
|
||||
upload = True
|
||||
self.filename = _tmp_path()
|
||||
upload = True
|
||||
|
||||
if os.path.exists(self.filename):
|
||||
if self.filename == "-":
|
||||
append = False
|
||||
|
||||
elif os.path.exists(self.filename):
|
||||
if not os.access(self.filename, os.W_OK):
|
||||
self.print_error("can't write to %s" % self.filename)
|
||||
self.print_error(f"can't write to {self.filename}")
|
||||
return 1
|
||||
|
||||
if os.stat(self.filename).st_size > 0 and self.overwrite:
|
||||
@@ -55,22 +65,42 @@ class RecordCommand(Command):
|
||||
append = False
|
||||
|
||||
elif os.stat(self.filename).st_size > 0 and not append:
|
||||
self.print_error("%s already exists, aborting" % self.filename)
|
||||
self.print_error("use --overwrite option if you want to overwrite existing recording")
|
||||
self.print_error("use --append option if you want to append to existing recording")
|
||||
self.print_error(f"{self.filename} already exists, aborting")
|
||||
self.print_error(
|
||||
"use --overwrite option "
|
||||
"if you want to overwrite existing recording"
|
||||
)
|
||||
self.print_error(
|
||||
"use --append option "
|
||||
"if you want to append to existing recording"
|
||||
)
|
||||
return 1
|
||||
|
||||
elif append:
|
||||
self.print_warning(
|
||||
f"{self.filename} does not exist, not appending"
|
||||
)
|
||||
append = False
|
||||
|
||||
if append:
|
||||
self.print_info("appending to asciicast at %s" % self.filename)
|
||||
self.print_info(f"appending to asciicast at {self.filename}")
|
||||
else:
|
||||
self.print_info("recording asciicast to %s" % self.filename)
|
||||
self.print_info(f"recording asciicast to {self.filename}")
|
||||
|
||||
if self.command:
|
||||
self.print_info("""exit opened program when you're done""")
|
||||
else:
|
||||
self.print_info("""press <ctrl-d> or type "exit" when you're done""")
|
||||
self.print_info(
|
||||
"""press <ctrl-d> or type "exit" when you're done"""
|
||||
)
|
||||
|
||||
vars = filter(None, map((lambda var: var.strip()), self.env_whitelist.split(',')))
|
||||
vars_: Any = filter(
|
||||
None,
|
||||
map(
|
||||
(lambda var: var.strip()), # type: ignore
|
||||
self.env_whitelist.split(","),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
recorder.record(
|
||||
@@ -80,27 +110,33 @@ class RecordCommand(Command):
|
||||
title=self.title,
|
||||
idle_time_limit=self.idle_time_limit,
|
||||
command_env=self.env,
|
||||
capture_env=vars,
|
||||
rec_stdin=self.rec_stdin,
|
||||
capture_env=vars_,
|
||||
record_stdin=self.record_stdin,
|
||||
writer=self.writer,
|
||||
notifier=self.notifier,
|
||||
key_bindings=self.key_bindings
|
||||
notify=self.notifier.notify,
|
||||
key_bindings=self.key_bindings,
|
||||
cols_override=self.cols_override,
|
||||
rows_override=self.rows_override,
|
||||
)
|
||||
except v2.LoadError:
|
||||
self.print_error("can only append to asciicast v2 format recordings")
|
||||
self.print_error(
|
||||
"can only append to asciicast v2 format recordings"
|
||||
)
|
||||
return 1
|
||||
|
||||
self.print_info("recording finished")
|
||||
|
||||
if upload:
|
||||
if not self.assume_yes:
|
||||
self.print_info("press <enter> to upload to %s, <ctrl-c> to save locally"
|
||||
% self.api.hostname())
|
||||
self.print_info(
|
||||
f"press <enter> to upload to {self.api.hostname()}"
|
||||
", <ctrl-c> to save locally"
|
||||
)
|
||||
try:
|
||||
sys.stdin.readline()
|
||||
except KeyboardInterrupt:
|
||||
self.print("\r", end="")
|
||||
self.print_info("asciicast saved to %s" % self.filename)
|
||||
self.print_info(f"asciicast saved to {self.filename}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
@@ -110,20 +146,20 @@ class RecordCommand(Command):
|
||||
self.print_warning(warn)
|
||||
|
||||
os.remove(self.filename)
|
||||
self.print(result.get('message') or result['url'])
|
||||
self.print(result.get("message") or result["url"])
|
||||
|
||||
except APIError as e:
|
||||
self.print("\r\x1b[A", end="")
|
||||
self.print_error("upload failed: %s" % str(e))
|
||||
self.print_error("retry later by running: asciinema upload %s" % self.filename)
|
||||
self.print_error(f"upload failed: {str(e)}")
|
||||
self.print_error(
|
||||
f"retry later by running: asciinema upload {self.filename}"
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
self.print_info("asciicast saved to %s" % self.filename)
|
||||
self.print_info(f"asciicast saved to {self.filename}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _tmp_path():
|
||||
fd, path = tempfile.mkstemp(suffix='-ascii.cast')
|
||||
os.close(fd)
|
||||
return path
|
||||
def _tmp_path() -> Optional[str]:
|
||||
return NamedTemporaryFile(suffix="-ascii.cast", delete=False).name
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
from asciinema.commands.command import Command
|
||||
from asciinema.api import APIError
|
||||
from typing import Any
|
||||
|
||||
from ..api import APIError
|
||||
from ..config import Config
|
||||
from .command import Command
|
||||
|
||||
|
||||
class UploadCommand(Command):
|
||||
|
||||
def __init__(self, args, config, env):
|
||||
def __init__(self, args: Any, config: Config, env: Any) -> None:
|
||||
Command.__init__(self, args, config, env)
|
||||
self.filename = args.filename
|
||||
|
||||
def execute(self):
|
||||
def execute(self) -> int:
|
||||
try:
|
||||
result, warn = self.api.upload_asciicast(self.filename)
|
||||
|
||||
if warn:
|
||||
self.print_warning(warn)
|
||||
|
||||
self.print(result.get('message') or result['url'])
|
||||
self.print(result.get("message") or result["url"])
|
||||
|
||||
except OSError as e:
|
||||
self.print_error("upload failed: %s" % str(e))
|
||||
self.print_error(f"upload failed: {str(e)}")
|
||||
return 1
|
||||
|
||||
except APIError as e:
|
||||
self.print_error("upload failed: %s" % str(e))
|
||||
self.print_error("retry later by running: asciinema upload %s" % self.filename)
|
||||
self.print_error(f"upload failed: {str(e)}")
|
||||
self.print_error(
|
||||
f"retry later by running: asciinema upload {self.filename}"
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@@ -1,168 +1,198 @@
|
||||
import os
|
||||
import os.path as path
|
||||
import sys
|
||||
import uuid
|
||||
import configparser
|
||||
import os
|
||||
from os import path
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
DEFAULT_API_URL: str = "https://asciinema.org"
|
||||
DEFAULT_RECORD_ENV: str = "SHELL,TERM"
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_API_URL = 'https://asciinema.org'
|
||||
DEFAULT_RECORD_ENV = 'SHELL,TERM'
|
||||
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self, config_home, env=None):
|
||||
def __init__(
|
||||
self,
|
||||
config_home: Any,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
self.config_home = config_home
|
||||
self.config_file_path = path.join(config_home, "config")
|
||||
self.install_id_path = path.join(self.config_home, 'install-id')
|
||||
self.install_id_path = path.join(self.config_home, "install-id")
|
||||
self.config = configparser.ConfigParser()
|
||||
self.config.read(self.config_file_path)
|
||||
self.env = env if env is not None else os.environ
|
||||
|
||||
def upgrade(self):
|
||||
def upgrade(self) -> None:
|
||||
try:
|
||||
self.install_id
|
||||
except ConfigError:
|
||||
id = self.__api_token() or self.__user_token() or self.__gen_install_id()
|
||||
self.__save_install_id(id)
|
||||
id_ = (
|
||||
self.__api_token()
|
||||
or self.__user_token()
|
||||
or self.__gen_install_id()
|
||||
)
|
||||
self.__save_install_id(id_)
|
||||
|
||||
items = {name: dict(section) for (name, section) in self.config.items()}
|
||||
if items == {'DEFAULT': {}, 'api': {'token': id}} or items == {'DEFAULT': {}, 'user': {'token': id}}:
|
||||
items = {
|
||||
name: dict(section) for (name, section) in self.config.items()
|
||||
}
|
||||
if items in (
|
||||
{"DEFAULT": {}, "api": {"token": id_}},
|
||||
{"DEFAULT": {}, "user": {"token": id_}},
|
||||
):
|
||||
os.remove(self.config_file_path)
|
||||
|
||||
if self.env.get('ASCIINEMA_API_TOKEN'):
|
||||
raise ConfigError('ASCIINEMA_API_TOKEN variable is no longer supported, please use ASCIINEMA_INSTALL_ID instead')
|
||||
if self.env.get("ASCIINEMA_API_TOKEN"):
|
||||
raise ConfigError(
|
||||
"ASCIINEMA_API_TOKEN variable is no longer supported"
|
||||
", please use ASCIINEMA_INSTALL_ID instead"
|
||||
)
|
||||
|
||||
def __read_install_id(self):
|
||||
def __read_install_id(self) -> Optional[str]:
|
||||
p = self.install_id_path
|
||||
if path.isfile(p):
|
||||
with open(p, 'r') as f:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
return None
|
||||
|
||||
def __gen_install_id(self):
|
||||
return str(uuid.uuid4())
|
||||
@staticmethod
|
||||
def __gen_install_id() -> str:
|
||||
return f"{uuid4()}"
|
||||
|
||||
def __save_install_id(self, id):
|
||||
def __save_install_id(self, id_: str) -> None:
|
||||
self.__create_config_home()
|
||||
|
||||
with open(self.install_id_path, 'w') as f:
|
||||
f.write(id)
|
||||
with open(self.install_id_path, "w", encoding="utf-8") as f:
|
||||
f.write(id_)
|
||||
|
||||
def __create_config_home(self):
|
||||
def __create_config_home(self) -> None:
|
||||
if not path.exists(self.config_home):
|
||||
os.makedirs(self.config_home)
|
||||
|
||||
def __api_token(self):
|
||||
def __api_token(self) -> Optional[str]:
|
||||
try:
|
||||
return self.config.get('api', 'token')
|
||||
return self.config.get("api", "token")
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def __user_token(self):
|
||||
def __user_token(self) -> Optional[str]:
|
||||
try:
|
||||
return self.config.get('user', 'token')
|
||||
return self.config.get("user", "token")
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def install_id(self):
|
||||
id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id()
|
||||
def install_id(self) -> str:
|
||||
id_ = self.env.get("ASCIINEMA_INSTALL_ID") or self.__read_install_id()
|
||||
|
||||
if id:
|
||||
return id
|
||||
else:
|
||||
raise ConfigError('no install ID found')
|
||||
if id_:
|
||||
return id_
|
||||
raise ConfigError("no install ID found")
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
def api_url(self) -> str:
|
||||
return self.env.get(
|
||||
'ASCIINEMA_API_URL',
|
||||
self.config.get('api', 'url', fallback=DEFAULT_API_URL)
|
||||
"ASCIINEMA_API_URL",
|
||||
self.config.get("api", "url", fallback=DEFAULT_API_URL),
|
||||
)
|
||||
|
||||
@property
|
||||
def record_stdin(self):
|
||||
return self.config.getboolean('record', 'stdin', fallback=False)
|
||||
def record_stdin(self) -> bool:
|
||||
return self.config.getboolean("record", "stdin", fallback=False)
|
||||
|
||||
@property
|
||||
def record_command(self):
|
||||
return self.config.get('record', 'command', fallback=None)
|
||||
def record_command(self) -> Optional[str]:
|
||||
return self.config.get("record", "command", fallback=None)
|
||||
|
||||
@property
|
||||
def record_env(self):
|
||||
return self.config.get('record', 'env', fallback=DEFAULT_RECORD_ENV)
|
||||
def record_env(self) -> str:
|
||||
return self.config.get("record", "env", fallback=DEFAULT_RECORD_ENV)
|
||||
|
||||
@property
|
||||
def record_idle_time_limit(self):
|
||||
fallback = self.config.getfloat('record', 'maxwait', fallback=None) # pre 2.0
|
||||
return self.config.getfloat('record', 'idle_time_limit', fallback=fallback)
|
||||
def record_idle_time_limit(self) -> Optional[float]:
|
||||
fallback = self.config.getfloat(
|
||||
"record", "maxwait", fallback=None
|
||||
) # pre 2.0
|
||||
return self.config.getfloat(
|
||||
"record", "idle_time_limit", fallback=fallback
|
||||
)
|
||||
|
||||
@property
|
||||
def record_yes(self):
|
||||
return self.config.getboolean('record', 'yes', fallback=False)
|
||||
def record_yes(self) -> bool:
|
||||
return self.config.getboolean("record", "yes", fallback=False)
|
||||
|
||||
@property
|
||||
def record_quiet(self):
|
||||
return self.config.getboolean('record', 'quiet', fallback=False)
|
||||
def record_quiet(self) -> bool:
|
||||
return self.config.getboolean("record", "quiet", fallback=False)
|
||||
|
||||
@property
|
||||
def record_prefix_key(self):
|
||||
return self.__get_key('record', 'prefix')
|
||||
def record_prefix_key(self) -> Any:
|
||||
return self.__get_key("record", "prefix")
|
||||
|
||||
@property
|
||||
def record_pause_key(self):
|
||||
return self.__get_key('record', 'pause', 'C-\\')
|
||||
def record_pause_key(self) -> Any:
|
||||
return self.__get_key("record", "pause", "C-\\")
|
||||
|
||||
@property
|
||||
def play_idle_time_limit(self):
|
||||
fallback = self.config.getfloat('play', 'maxwait', fallback=None) # pre 2.0
|
||||
return self.config.getfloat('play', 'idle_time_limit', fallback=fallback)
|
||||
def play_idle_time_limit(self) -> Optional[float]:
|
||||
fallback = self.config.getfloat(
|
||||
"play", "maxwait", fallback=None
|
||||
) # pre 2.0
|
||||
return self.config.getfloat(
|
||||
"play", "idle_time_limit", fallback=fallback
|
||||
)
|
||||
|
||||
@property
|
||||
def play_speed(self):
|
||||
return self.config.getfloat('play', 'speed', fallback=1.0)
|
||||
def play_speed(self) -> float:
|
||||
return self.config.getfloat("play", "speed", fallback=1.0)
|
||||
|
||||
@property
|
||||
def play_pause_key(self):
|
||||
return self.__get_key('play', 'pause', ' ')
|
||||
def play_pause_key(self) -> Any:
|
||||
return self.__get_key("play", "pause", " ")
|
||||
|
||||
@property
|
||||
def play_step_key(self):
|
||||
return self.__get_key('play', 'step', '.')
|
||||
def play_step_key(self) -> Any:
|
||||
return self.__get_key("play", "step", ".")
|
||||
|
||||
@property
|
||||
def notifications_enabled(self):
|
||||
return self.config.getboolean('notifications', 'enabled', fallback=True)
|
||||
def notifications_enabled(self) -> bool:
|
||||
return self.config.getboolean(
|
||||
"notifications", "enabled", fallback=True
|
||||
)
|
||||
|
||||
@property
|
||||
def notifications_command(self):
|
||||
return self.config.get('notifications', 'command', fallback=None)
|
||||
def notifications_command(self) -> Optional[str]:
|
||||
return self.config.get("notifications", "command", fallback=None)
|
||||
|
||||
def __get_key(self, section, name, default=None):
|
||||
key = self.config.get(section, name + '_key', fallback=default)
|
||||
def __get_key(self, section: str, name: str, default: Any = None) -> Any:
|
||||
key = self.config.get(section, f"{name}_key", fallback=default)
|
||||
|
||||
if key:
|
||||
if len(key) == 3:
|
||||
upper_key = key.upper()
|
||||
|
||||
if upper_key[0] == 'C' and upper_key[1] == '-':
|
||||
if upper_key[0] == "C" and upper_key[1] == "-":
|
||||
return bytes([ord(upper_key[2]) - 0x40])
|
||||
else:
|
||||
raise ConfigError('invalid {name} key definition \'{key}\' - use: {name}_key = C-x (with control key modifier), or {name}_key = x (with no modifier)'.format(name=name, key=key))
|
||||
else:
|
||||
return key.encode('utf-8')
|
||||
raise ConfigError(
|
||||
f"invalid {name} key definition '{key}' - use"
|
||||
f": {name}_key = C-x (with control key modifier)"
|
||||
f", or {name}_key = x (with no modifier)"
|
||||
)
|
||||
return key.encode("utf-8")
|
||||
return None
|
||||
|
||||
|
||||
def get_config_home(env=os.environ):
|
||||
def get_config_home(env: Any = None) -> Any:
|
||||
if env is None:
|
||||
env = os.environ
|
||||
env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
|
||||
env_xdg_config_home = env.get("XDG_CONFIG_HOME")
|
||||
env_home = env.get("HOME")
|
||||
|
||||
config_home = None
|
||||
config_home: Optional[str] = None
|
||||
|
||||
if env_asciinema_config_home:
|
||||
config_home = env_asciinema_config_home
|
||||
@@ -175,12 +205,16 @@ def get_config_home(env=os.environ):
|
||||
else:
|
||||
config_home = path.join(env_home, ".config", "asciinema")
|
||||
else:
|
||||
raise Exception("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME")
|
||||
raise Exception(
|
||||
"need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME"
|
||||
)
|
||||
|
||||
return config_home
|
||||
|
||||
|
||||
def load(env=os.environ):
|
||||
def load(env: Any = None) -> Config:
|
||||
if env is None:
|
||||
env = os.environ
|
||||
config = Config(get_config_home(env), env)
|
||||
config.upgrade()
|
||||
return config
|
||||
|
||||
44
asciinema/file_writer.py
Normal file
44
asciinema/file_writer.py
Normal 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
|
||||
@@ -1,83 +1,121 @@
|
||||
import os.path
|
||||
import shutil
|
||||
import subprocess
|
||||
from os import environ, path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
|
||||
class Notifier():
|
||||
def is_available(self):
|
||||
class Notifier:
|
||||
def __init__(self, cmd: str) -> None:
|
||||
self.cmd = cmd
|
||||
|
||||
@staticmethod
|
||||
def get_icon_path() -> Optional[str]:
|
||||
path_ = path.join(
|
||||
path.dirname(path.realpath(__file__)),
|
||||
"data/icon-256x256.png",
|
||||
)
|
||||
|
||||
if path.exists(path_):
|
||||
return path_
|
||||
return None
|
||||
|
||||
def args(self, _text: str) -> List[str]:
|
||||
return ["/bin/sh", "-c", self.cmd]
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return shutil.which(self.cmd) is not None
|
||||
|
||||
def notify(self, text):
|
||||
subprocess.run(self.args(text), capture_output=True)
|
||||
# we don't want to print *ANYTHING* to the terminal
|
||||
def notify(self, text: str) -> None:
|
||||
# We do not want to raise a `CalledProcessError` on command failure.
|
||||
# pylint: disable=subprocess-run-check
|
||||
# We do not want to print *ANYTHING* to the terminal
|
||||
# so we capture and ignore all output
|
||||
|
||||
def get_icon_path(self):
|
||||
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data/icon-256x256.png")
|
||||
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
subprocess.run(self.args(text), capture_output=True)
|
||||
|
||||
|
||||
class AppleScriptNotifier(Notifier):
|
||||
cmd = "osascript"
|
||||
def __init__(self) -> None:
|
||||
super().__init__("osascript")
|
||||
|
||||
def args(self, text):
|
||||
def args(self, text: str) -> List[str]:
|
||||
text = text.replace('"', '\\"')
|
||||
return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)]
|
||||
return [
|
||||
self.cmd,
|
||||
"-e",
|
||||
f'display notification "{text}" with title "asciinema"',
|
||||
]
|
||||
|
||||
|
||||
class LibNotifyNotifier(Notifier):
|
||||
cmd = "notify-send"
|
||||
def __init__(self) -> None:
|
||||
super().__init__("notify-send")
|
||||
|
||||
def args(self, text):
|
||||
def args(self, text: str) -> List[str]:
|
||||
icon_path = self.get_icon_path()
|
||||
|
||||
if icon_path is not None:
|
||||
return ['notify-send', '-i', icon_path, 'asciinema', text]
|
||||
else:
|
||||
return ['notify-send', 'asciinema', text]
|
||||
return [self.cmd, "-i", icon_path, "asciinema", text]
|
||||
return [self.cmd, "asciinema", text]
|
||||
|
||||
|
||||
class TerminalNotifier(Notifier):
|
||||
cmd = "terminal-notifier"
|
||||
def __init__(self) -> None:
|
||||
super().__init__("terminal-notifier")
|
||||
|
||||
def args(self, text):
|
||||
def args(self, text: str) -> List[str]:
|
||||
icon_path = self.get_icon_path()
|
||||
|
||||
if icon_path is not None:
|
||||
return ['terminal-notifier', '-title', 'asciinema', '-message', text, '-appIcon', icon_path]
|
||||
else:
|
||||
return ['terminal-notifier', '-title', 'asciinema', '-message', text]
|
||||
return [
|
||||
"terminal-notifier",
|
||||
"-title",
|
||||
"asciinema",
|
||||
"-message",
|
||||
text,
|
||||
"-appIcon",
|
||||
icon_path,
|
||||
]
|
||||
return [
|
||||
"terminal-notifier",
|
||||
"-title",
|
||||
"asciinema",
|
||||
"-message",
|
||||
text,
|
||||
]
|
||||
|
||||
|
||||
class CustomCommandNotifier(Notifier):
|
||||
def __init__(self, command):
|
||||
Notifier.__init__(self)
|
||||
self.command = command
|
||||
def env(self, text: str) -> Dict[str, str]:
|
||||
icon_path = self.get_icon_path()
|
||||
env = environ.copy()
|
||||
env["TEXT"] = text
|
||||
if icon_path is not None:
|
||||
env["ICON_PATH"] = icon_path
|
||||
return env
|
||||
|
||||
def notify(self, text):
|
||||
args = ['/bin/sh', '-c', self.command]
|
||||
env = os.environ.copy()
|
||||
env['TEXT'] = text
|
||||
env['ICON_PATH'] = self.get_icon_path()
|
||||
subprocess.run(args, env=env, capture_output=True)
|
||||
def notify(self, text: str) -> None:
|
||||
# We do not want to raise a `CalledProcessError` on command failure.
|
||||
# pylint: disable=subprocess-run-check
|
||||
subprocess.run(
|
||||
self.args(text), env=self.env(text), capture_output=True
|
||||
)
|
||||
|
||||
|
||||
class NoopNotifier():
|
||||
def notify(self, text):
|
||||
class NoopNotifier: # pylint: disable=too-few-public-methods
|
||||
def notify(self, text: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def get_notifier(enabled=True, command=None):
|
||||
def get_notifier(
|
||||
enabled: bool = True, command: Optional[str] = None
|
||||
) -> Union[Notifier, NoopNotifier]:
|
||||
if enabled:
|
||||
if command:
|
||||
return CustomCommandNotifier(command)
|
||||
else:
|
||||
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
|
||||
n = c()
|
||||
for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
|
||||
n = c()
|
||||
|
||||
if n.is_available():
|
||||
return n
|
||||
if n.is_available():
|
||||
return n
|
||||
|
||||
return NoopNotifier()
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional, TextIO, Union
|
||||
|
||||
import asciinema.asciicast.events as ev
|
||||
from asciinema.term import raw, read_blocking
|
||||
from .asciicast import events as ev
|
||||
from .asciicast.v1 import Asciicast as v1
|
||||
from .asciicast.v2 import Asciicast as v2
|
||||
from .tty_ import raw, read_blocking
|
||||
|
||||
|
||||
class Player:
|
||||
|
||||
def play(self, asciicast, idle_time_limit=None, speed=1.0, key_bindings={}):
|
||||
class Player: # pylint: disable=too-few-public-methods
|
||||
def play(
|
||||
self,
|
||||
asciicast: Union[v1, v2],
|
||||
idle_time_limit: Optional[int] = None,
|
||||
speed: float = 1.0,
|
||||
key_bindings: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
if key_bindings is None:
|
||||
key_bindings = {}
|
||||
try:
|
||||
stdin = open('/dev/tty')
|
||||
with raw(stdin.fileno()):
|
||||
self._play(asciicast, idle_time_limit, speed, stdin, key_bindings)
|
||||
except Exception:
|
||||
with open("/dev/tty", "rt", encoding="utf-8") as stdin:
|
||||
with raw(stdin.fileno()):
|
||||
self._play(
|
||||
asciicast, idle_time_limit, speed, stdin, key_bindings
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self._play(asciicast, idle_time_limit, speed, None, key_bindings)
|
||||
|
||||
def _play(self, asciicast, idle_time_limit, speed, stdin, key_bindings):
|
||||
@staticmethod
|
||||
def _play( # pylint: disable=too-many-locals
|
||||
asciicast: Union[v1, v2],
|
||||
idle_time_limit: Optional[int],
|
||||
speed: float,
|
||||
stdin: Optional[TextIO],
|
||||
key_bindings: Dict[str, Any],
|
||||
) -> None:
|
||||
idle_time_limit = idle_time_limit or asciicast.idle_time_limit
|
||||
pause_key = key_bindings.get('pause')
|
||||
step_key = key_bindings.get('step')
|
||||
pause_key = key_bindings.get("pause")
|
||||
step_key = key_bindings.get("step")
|
||||
|
||||
stdout = asciicast.stdout_events()
|
||||
stdout = ev.to_relative_time(stdout)
|
||||
@@ -30,7 +48,7 @@ class Player:
|
||||
base_time = time.time()
|
||||
ctrl_c = False
|
||||
paused = False
|
||||
pause_time = None
|
||||
pause_time: Optional[float] = None
|
||||
|
||||
for t, _type, text in stdout:
|
||||
delay = t - (time.time() - base_time)
|
||||
@@ -46,7 +64,8 @@ class Player:
|
||||
|
||||
if data == pause_key:
|
||||
paused = False
|
||||
base_time = base_time + (time.time() - pause_time)
|
||||
assert pause_time is not None
|
||||
base_time += time.time() - pause_time
|
||||
break
|
||||
|
||||
if data == step_key:
|
||||
|
||||
176
asciinema/pty.py
176
asciinema/pty.py
@@ -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
183
asciinema/pty_.py
Normal 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
0
asciinema/py.typed
Normal file
@@ -1,102 +1,191 @@
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type
|
||||
|
||||
import asciinema.asciicast.v2 as v2
|
||||
import asciinema.pty as pty
|
||||
import asciinema.term as term
|
||||
from asciinema.async_worker import async_worker
|
||||
from . import pty_ as pty # avoid collisions with standard library `pty`
|
||||
from .asciicast import v2
|
||||
from .asciicast.v2 import writer as w2
|
||||
from .async_worker import async_worker
|
||||
|
||||
|
||||
def record(path, command=None, append=False, idle_time_limit=None,
|
||||
rec_stdin=False, title=None, metadata=None, command_env=None,
|
||||
capture_env=None, writer=v2.writer, record=pty.record, notifier=None,
|
||||
key_bindings={}):
|
||||
def record( # pylint: disable=too-many-arguments,too-many-locals
|
||||
path_: str,
|
||||
command: Optional[str] = None,
|
||||
append: bool = False,
|
||||
idle_time_limit: Optional[float] = None,
|
||||
record_stdin: bool = False,
|
||||
title: Optional[str] = None,
|
||||
command_env: Optional[Dict[str, str]] = None,
|
||||
capture_env: Optional[List[str]] = None,
|
||||
writer: Type[w2] = v2.writer,
|
||||
record_: Callable[..., None] = pty.record,
|
||||
notify: Callable[[str], None] = lambda _: None,
|
||||
key_bindings: Optional[Dict[str, Any]] = None,
|
||||
cols_override: Optional[int] = None,
|
||||
rows_override: Optional[int] = None,
|
||||
) -> None:
|
||||
if command is None:
|
||||
command = os.environ.get('SHELL') or 'sh'
|
||||
command = os.environ.get("SHELL", "sh")
|
||||
|
||||
if command_env is None:
|
||||
command_env = os.environ.copy()
|
||||
|
||||
command_env['ASCIINEMA_REC'] = '1'
|
||||
if key_bindings is None:
|
||||
key_bindings = {}
|
||||
|
||||
command_env["ASCIINEMA_REC"] = "1"
|
||||
|
||||
if capture_env is None:
|
||||
capture_env = ['SHELL', 'TERM']
|
||||
capture_env = ["SHELL", "TERM"]
|
||||
|
||||
w, h = term.get_size()
|
||||
time_offset: float = 0
|
||||
|
||||
full_metadata = {
|
||||
'width': w,
|
||||
'height': h,
|
||||
'timestamp': int(time.time())
|
||||
}
|
||||
if append and os.stat(path_).st_size > 0:
|
||||
time_offset = v2.get_duration(path_)
|
||||
|
||||
full_metadata.update(metadata or {})
|
||||
with tty_fds() as (tty_stdin_fd, tty_stdout_fd), async_notifier(
|
||||
notify
|
||||
) as _notifier:
|
||||
get_tty_size = _get_tty_size(
|
||||
tty_stdout_fd, cols_override, rows_override
|
||||
)
|
||||
cols, rows = get_tty_size()
|
||||
metadata = build_metadata(
|
||||
cols, rows, idle_time_limit, capture_env, command_env, title
|
||||
)
|
||||
|
||||
if idle_time_limit is not None:
|
||||
full_metadata['idle_time_limit'] = idle_time_limit
|
||||
sync_writer = writer(
|
||||
path_, metadata, append, on_error=_notifier.queue.put
|
||||
)
|
||||
|
||||
if capture_env:
|
||||
full_metadata['env'] = {var: command_env.get(var) for var in capture_env}
|
||||
|
||||
if title:
|
||||
full_metadata['title'] = title
|
||||
|
||||
time_offset = 0
|
||||
|
||||
if append and os.stat(path).st_size > 0:
|
||||
time_offset = v2.get_duration(path)
|
||||
|
||||
with async_writer(writer, path, full_metadata, append) as w:
|
||||
with async_notifier(notifier) as n:
|
||||
record(
|
||||
['sh', '-c', command],
|
||||
w,
|
||||
with async_writer(sync_writer, time_offset, record_stdin) as _writer:
|
||||
record_(
|
||||
["sh", "-c", command],
|
||||
command_env,
|
||||
rec_stdin,
|
||||
time_offset,
|
||||
n,
|
||||
key_bindings
|
||||
_writer,
|
||||
get_tty_size,
|
||||
_notifier.notify,
|
||||
key_bindings,
|
||||
tty_stdin_fd=tty_stdin_fd,
|
||||
tty_stdout_fd=tty_stdout_fd,
|
||||
)
|
||||
|
||||
|
||||
class tty_fds:
|
||||
def __init__(self) -> None:
|
||||
self.stdin_file: Optional[TextIO] = None
|
||||
self.stdout_file: Optional[TextIO] = None
|
||||
|
||||
def __enter__(self) -> Tuple[int, int]:
|
||||
try:
|
||||
self.stdin_file = open("/dev/tty", "rt", encoding="utf_8")
|
||||
except OSError:
|
||||
self.stdin_file = open("/dev/null", "rt", encoding="utf_8")
|
||||
|
||||
try:
|
||||
self.stdout_file = open("/dev/tty", "wt", encoding="utf_8")
|
||||
except OSError:
|
||||
self.stdout_file = open("/dev/null", "wt", encoding="utf_8")
|
||||
|
||||
return (self.stdin_file.fileno(), self.stdout_file.fileno())
|
||||
|
||||
def __exit__(self, type_: str, value: str, traceback: str) -> None:
|
||||
assert self.stdin_file is not None
|
||||
assert self.stdout_file is not None
|
||||
self.stdin_file.close()
|
||||
self.stdout_file.close()
|
||||
|
||||
|
||||
def build_metadata( # pylint: disable=too-many-arguments
|
||||
cols: int,
|
||||
rows: int,
|
||||
idle_time_limit: Optional[float],
|
||||
capture_env: List[str],
|
||||
env: Dict[str, str],
|
||||
title: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
metadata: Dict[str, Any] = {
|
||||
"width": cols,
|
||||
"height": rows,
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
if idle_time_limit is not None:
|
||||
metadata["idle_time_limit"] = idle_time_limit
|
||||
|
||||
metadata["env"] = {var: env.get(var) for var in capture_env}
|
||||
|
||||
if title:
|
||||
metadata["title"] = title
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
class async_writer(async_worker):
|
||||
def __init__(self, writer, path, metadata, append=False):
|
||||
def __init__(
|
||||
self, writer: w2, time_offset: float, record_stdin: bool
|
||||
) -> None:
|
||||
async_worker.__init__(self)
|
||||
self.writer = writer
|
||||
self.path = path
|
||||
self.metadata = metadata
|
||||
self.append = append
|
||||
self.time_offset = time_offset
|
||||
self.record_stdin = record_stdin
|
||||
|
||||
def write_stdin(self, ts, data):
|
||||
self.enqueue([ts, 'i', data])
|
||||
def write_stdin(self, ts: float, data: Any) -> None:
|
||||
if self.record_stdin:
|
||||
self.enqueue([ts, "i", data])
|
||||
|
||||
def write_stdout(self, ts, data):
|
||||
self.enqueue([ts, 'o', data])
|
||||
def write_stdout(self, ts: float, data: Any) -> None:
|
||||
self.enqueue([ts, "o", data])
|
||||
|
||||
def run(self):
|
||||
with self.writer(self.path, metadata=self.metadata, append=self.append) as w:
|
||||
def run(self) -> None:
|
||||
with self.writer as w:
|
||||
event: Tuple[float, str, Any]
|
||||
for event in iter(self.queue.get, None):
|
||||
assert event is not None
|
||||
ts, etype, data = event
|
||||
|
||||
if etype == 'o':
|
||||
w.write_stdout(ts, data)
|
||||
elif etype == 'i':
|
||||
w.write_stdin(ts, data)
|
||||
if etype == "o":
|
||||
w.write_stdout(self.time_offset + ts, data)
|
||||
elif etype == "i":
|
||||
w.write_stdin(self.time_offset + ts, data)
|
||||
|
||||
|
||||
class async_notifier(async_worker):
|
||||
def __init__(self, notifier):
|
||||
def __init__(self, notify: Callable[[str], None]) -> None:
|
||||
async_worker.__init__(self)
|
||||
self.notifier = notifier
|
||||
self._notify = notify
|
||||
|
||||
def notify(self, text):
|
||||
def notify(self, text: str) -> None:
|
||||
self.enqueue(text)
|
||||
|
||||
def perform(self, text):
|
||||
def perform(self, text: str) -> None:
|
||||
try:
|
||||
if self.notifier:
|
||||
self.notifier.notify(text)
|
||||
except:
|
||||
self._notify(text)
|
||||
except: # pylint: disable=bare-except # noqa: E722
|
||||
# we catch *ALL* exceptions here because we don't want failed
|
||||
# notification to crash the recording session
|
||||
pass
|
||||
|
||||
|
||||
def _get_tty_size(
|
||||
fd: int, cols_override: Optional[int], rows_override: Optional[int]
|
||||
) -> Callable[[], Tuple[int, int]]:
|
||||
if cols_override is not None and rows_override is not None:
|
||||
|
||||
def fixed_size() -> Tuple[int, int]:
|
||||
return (cols_override, rows_override) # type: ignore
|
||||
|
||||
return fixed_size
|
||||
|
||||
if not os.isatty(fd):
|
||||
|
||||
def fallback_size() -> Tuple[int, int]:
|
||||
return (cols_override or 80, rows_override or 24)
|
||||
|
||||
return fallback_size
|
||||
|
||||
def size() -> Tuple[int, int]:
|
||||
cols, rows = os.get_terminal_size(fd)
|
||||
return (cols_override or cols, rows_override or rows)
|
||||
|
||||
return size
|
||||
|
||||
@@ -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
34
asciinema/tty_.py
Normal 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""
|
||||
@@ -1,94 +1,124 @@
|
||||
import codecs
|
||||
import sys
|
||||
import uuid
|
||||
import io
|
||||
import base64
|
||||
import http
|
||||
|
||||
from urllib.request import Request, urlopen
|
||||
import io
|
||||
import sys
|
||||
from base64 import b64encode
|
||||
from http.client import HTTPResponse
|
||||
from typing import Any, Dict, Generator, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from .http_adapter import HTTPConnectionError
|
||||
|
||||
|
||||
class MultipartFormdataEncoder:
|
||||
def __init__(self):
|
||||
self.boundary = uuid.uuid4().hex
|
||||
self.content_type = 'multipart/form-data; boundary={}'.format(self.boundary)
|
||||
def __init__(self) -> None:
|
||||
self.boundary = uuid4().hex
|
||||
self.content_type = f"multipart/form-data; boundary={self.boundary}"
|
||||
|
||||
@classmethod
|
||||
def u(cls, s):
|
||||
def u(cls, s: Any) -> Any:
|
||||
if sys.hexversion >= 0x03000000 and isinstance(s, bytes):
|
||||
s = s.decode('utf-8')
|
||||
s = s.decode("utf-8")
|
||||
return s
|
||||
|
||||
def iter(self, fields, files):
|
||||
def iter(
|
||||
self, fields: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
|
||||
) -> Generator[Tuple[bytes, int], None, None]:
|
||||
"""
|
||||
fields is a dict of {name: value} for regular form fields.
|
||||
files is a dict of {name: (filename, file-type)} for data to be uploaded as files
|
||||
Yield body's chunk as bytes
|
||||
fields: {name: value} for regular form fields.
|
||||
files: {name: (filename, file-type)} for data to be uploaded as files
|
||||
|
||||
yield body's chunk as bytes
|
||||
"""
|
||||
encoder = codecs.getencoder('utf-8')
|
||||
encoder = codecs.getencoder("utf-8")
|
||||
for (key, value) in fields.items():
|
||||
key = self.u(key)
|
||||
yield encoder('--{}\r\n'.format(self.boundary))
|
||||
yield encoder(self.u('Content-Disposition: form-data; name="{}"\r\n').format(key))
|
||||
yield encoder('\r\n')
|
||||
if isinstance(value, int) or isinstance(value, float):
|
||||
yield encoder(f"--{self.boundary}\r\n")
|
||||
yield encoder(
|
||||
self.u(f'content-disposition: form-data; name="{key}"\r\n')
|
||||
)
|
||||
yield encoder("\r\n")
|
||||
if isinstance(value, (int, float)):
|
||||
value = str(value)
|
||||
yield encoder(self.u(value))
|
||||
yield encoder('\r\n')
|
||||
yield encoder("\r\n")
|
||||
for (key, filename_and_f) in files.items():
|
||||
filename, f = filename_and_f
|
||||
key = self.u(key)
|
||||
filename = self.u(filename)
|
||||
yield encoder('--{}\r\n'.format(self.boundary))
|
||||
yield encoder(self.u('Content-Disposition: form-data; name="{}"; filename="{}"\r\n').format(key, filename))
|
||||
yield encoder('Content-Type: application/octet-stream\r\n')
|
||||
yield encoder('\r\n')
|
||||
yield encoder(f"--{self.boundary}\r\n")
|
||||
yield encoder(
|
||||
self.u(
|
||||
"content-disposition: form-data"
|
||||
f'; name="{key}"'
|
||||
f'; filename="{filename}"\r\n'
|
||||
)
|
||||
)
|
||||
yield encoder("content-type: application/octet-stream\r\n")
|
||||
yield encoder("\r\n")
|
||||
data = f.read()
|
||||
yield (data, len(data))
|
||||
yield encoder('\r\n')
|
||||
yield encoder('--{}--\r\n'.format(self.boundary))
|
||||
yield encoder("\r\n")
|
||||
yield encoder(f"--{self.boundary}--\r\n")
|
||||
|
||||
def encode(self, fields, files):
|
||||
def encode(
|
||||
self, fields: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
|
||||
) -> Tuple[str, bytes]:
|
||||
body = io.BytesIO()
|
||||
for chunk, chunk_len in self.iter(fields, files):
|
||||
for chunk, _ in self.iter(fields, files):
|
||||
body.write(chunk)
|
||||
return self.content_type, body.getvalue()
|
||||
|
||||
|
||||
class URLLibHttpAdapter:
|
||||
class URLLibHttpAdapter: # pylint: disable=too-few-public-methods
|
||||
def post( # pylint: disable=too-many-arguments,too-many-locals
|
||||
self,
|
||||
url: str,
|
||||
fields: Optional[Dict[str, Any]] = None,
|
||||
files: Optional[Dict[str, Tuple[str, Any]]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> Tuple[Any, Optional[Dict[str, str]], bytes]:
|
||||
# avoid dangerous mutable default arguments
|
||||
if fields is None:
|
||||
fields = {}
|
||||
if files is None:
|
||||
files = {}
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
def post(self, url, fields={}, files={}, headers={}, username=None, password=None):
|
||||
content_type, body = MultipartFormdataEncoder().encode(fields, files)
|
||||
|
||||
headers = headers.copy()
|
||||
headers["Content-Type"] = content_type
|
||||
headers["content-type"] = content_type
|
||||
|
||||
if password:
|
||||
auth = "%s:%s" % (username, password)
|
||||
encoded_auth = base64.b64encode(bytes(auth, "utf-8"))
|
||||
headers["Authorization"] = b"Basic " + encoded_auth
|
||||
encoded_auth = b64encode(
|
||||
f"{username}:{password}".encode("utf_8")
|
||||
).decode("utf_8")
|
||||
headers["authorization"] = f"Basic {encoded_auth}"
|
||||
|
||||
request = Request(url, data=body, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
response = urlopen(request)
|
||||
status = response.status
|
||||
headers = self._parse_headers(response)
|
||||
body = response.read().decode('utf-8')
|
||||
with urlopen(request) as response:
|
||||
status = response.status
|
||||
headers = self._parse_headers(response)
|
||||
body = response.read().decode("utf-8")
|
||||
except HTTPError as e:
|
||||
status = e.code
|
||||
headers = {}
|
||||
body = e.read().decode('utf-8')
|
||||
body = e.read()
|
||||
except (http.client.RemoteDisconnected, URLError) as e:
|
||||
raise HTTPConnectionError(str(e))
|
||||
raise HTTPConnectionError(str(e)) from e
|
||||
|
||||
return (status, headers, body)
|
||||
|
||||
def _parse_headers(self, response):
|
||||
headers = {}
|
||||
for k, v in response.getheaders():
|
||||
headers[k.lower()] = v
|
||||
@staticmethod
|
||||
def _parse_headers(response: HTTPResponse) -> Dict[str, str]:
|
||||
headers = {k.lower(): v for k, v in response.getheaders()}
|
||||
|
||||
return headers
|
||||
|
||||
@@ -177,8 +177,7 @@ easily fixed in the old format:
|
||||
Due to file structure change (standard JSON => newline-delimited JSON) version 2
|
||||
is not backwards compatible with version 1. Support for v2 has been added in:
|
||||
|
||||
* [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0
|
||||
(to be released, currently on development branch)
|
||||
* [asciinema terminal recorder](https://github.com/asciinema/asciinema) - 2.0.0
|
||||
* [asciinema web player](https://github.com/asciinema/asciinema-player) - 2.6.0
|
||||
* [asciinema server](https://github.com/asciinema/asciinema-server) - v20171105
|
||||
tag in git repository
|
||||
|
||||
@@ -2,4 +2,3 @@ VERSION=`python3 -c "import asciinema; print(asciinema.__version__)"`
|
||||
|
||||
asciinema.1: asciinema.1.md
|
||||
pandoc asciinema.1.md -s -t man -o asciinema.1 -V header:"Version $(VERSION), `date +%Y-%m-%d`"
|
||||
|
||||
|
||||
353
man/asciinema.1
353
man/asciinema.1
@@ -1,225 +1,241 @@
|
||||
.\" Automatically generated by Pandoc 2.1.3
|
||||
.\" Automatically generated by Pandoc 2.18
|
||||
.\"
|
||||
.TH "ASCIINEMA" "1" "" "Version 2.0.1" "asciinema"
|
||||
.\" Define V font for inline verbatim, using C font in formats
|
||||
.\" that render this, and otherwise B font.
|
||||
.ie "\f[CB]x\f[]"x" \{\
|
||||
. ftr V B
|
||||
. ftr VI BI
|
||||
. ftr VB B
|
||||
. ftr VBI BI
|
||||
.\}
|
||||
.el \{\
|
||||
. ftr V CR
|
||||
. ftr VI CI
|
||||
. ftr VB CB
|
||||
. ftr VBI CBI
|
||||
.\}
|
||||
.TH "ASCIINEMA" "1" "" "Version 2.0.1" "Version 2.1.0, 2022-05-07"
|
||||
.hy
|
||||
.SH NAME
|
||||
.PP
|
||||
\f[B]asciinema\f[] \- terminal session recorder
|
||||
\f[B]asciinema\f[R] - terminal session recorder
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\f[B]asciinema \-\-version\f[]
|
||||
\f[B]asciinema --version\f[R]
|
||||
.PD 0
|
||||
.P
|
||||
.PD
|
||||
\f[B]asciinema\f[] \f[I]command\f[] [\f[I]options\f[]] [\f[I]args\f[]]
|
||||
\f[B]asciinema\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
|
||||
[\f[I]args\f[R]]
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
asciinema lets you easily record terminal sessions and replay them in a
|
||||
terminal as well as in a web browser.
|
||||
asciinema lets you easily record terminal sessions, replay them in a
|
||||
terminal as well as in a web browser and share them on the web.
|
||||
asciinema is Free and Open Source Software licensed under the GNU
|
||||
General Public License v3.
|
||||
.SH COMMANDS
|
||||
.PP
|
||||
asciinema is composed of multiple commands, similar to \f[C]git\f[],
|
||||
\f[C]apt\-get\f[] or \f[C]brew\f[].
|
||||
asciinema is composed of multiple commands, similar to \f[V]git\f[R],
|
||||
\f[V]apt-get\f[R] or \f[V]brew\f[R].
|
||||
.PP
|
||||
When you run \f[B]asciinema\f[] with no arguments help message is
|
||||
When you run \f[B]asciinema\f[R] with no arguments a help message is
|
||||
displayed, listing all available commands with their options.
|
||||
.SS rec [\f[I]filename\f[]]
|
||||
.SS rec [\f[I]filename\f[R]]
|
||||
.PP
|
||||
Record terminal session.
|
||||
.PP
|
||||
By running \f[B]asciinema rec [filename]\f[] you start a new recording
|
||||
By running \f[B]asciinema rec [filename]\f[R] you start a new recording
|
||||
session.
|
||||
The command (process) that is recorded can be specified with
|
||||
\f[B]\-c\f[] option (see below), and defaults to \f[B]$SHELL\f[] which
|
||||
\f[B]-c\f[R] option (see below), and defaults to \f[B]$SHELL\f[R] which
|
||||
is what you want in most cases.
|
||||
.PP
|
||||
You can temporarily pause recording of terminal by pressing Ctrl+\[rs].
|
||||
This is useful when you want to execute some commands during the
|
||||
recording session that should not be captured (e.g.\ pasting secrets).
|
||||
Resume by pressing Ctrl+\[rs] again.
|
||||
.PP
|
||||
Recording finishes when you exit the shell (hit Ctrl+D or type
|
||||
\f[C]exit\f[]).
|
||||
\f[V]exit\f[R]).
|
||||
If the recorded process is not a shell then recording finishes when the
|
||||
process exits.
|
||||
.PP
|
||||
If the \f[I]filename\f[] argument is omitted then (after asking for
|
||||
If the \f[I]filename\f[R] argument is omitted then (after asking for
|
||||
confirmation) the resulting asciicast is uploaded to
|
||||
asciinema\-server (https://github.com/asciinema/asciinema-server) (by
|
||||
asciinema-server (https://github.com/asciinema/asciinema-server) (by
|
||||
default to asciinema.org), where it can be watched and shared.
|
||||
.PP
|
||||
If the \f[I]filename\f[] argument is given then the resulting recording
|
||||
(called asciicast (doc/asciicast-v2.md)) is saved to a local file.
|
||||
It can later be replayed with \f[B]asciinema play <filename>\f[] and/or
|
||||
uploaded to asciinema server with \f[B]asciinema upload <filename>\f[].
|
||||
If the \f[I]filename\f[R] argument is given then the resulting recording
|
||||
(called asciicast) is saved to a local file.
|
||||
It can later be replayed with \f[B]asciinema play <filename>\f[R] and/or
|
||||
uploaded to asciinema server with \f[B]asciinema upload <filename>\f[R].
|
||||
.PP
|
||||
\f[B]ASCIINEMA_REC=1\f[] is added to recorded process environment
|
||||
\f[B]ASCIINEMA_REC=1\f[R] is added to recorded process environment
|
||||
variables.
|
||||
This can be used by your shell's config file (\f[C]\&.bashrc\f[],
|
||||
\f[C]\&.zshrc\f[]) to alter the prompt or play a sound when the shell is
|
||||
This can be used by your shell\[cq]s config file (\f[V].bashrc\f[R],
|
||||
\f[V].zshrc\f[R]) to alter the prompt or play a sound when the shell is
|
||||
being recorded.
|
||||
.TP
|
||||
.B Available options:
|
||||
Available options:
|
||||
\
|
||||
.RS
|
||||
.TP
|
||||
.B \f[C]\-\-stdin\f[]
|
||||
\f[V]--stdin\f[R]
|
||||
Enable stdin (keyboard) recording (see below)
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-\-append\f[]
|
||||
\f[V]--append\f[R]
|
||||
Append to existing recording
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-\-raw\f[]
|
||||
\f[V]--raw\f[R]
|
||||
Save raw STDOUT output, without timing information or other metadata
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-\-overwrite\f[]
|
||||
\f[V]--overwrite\f[R]
|
||||
Overwrite the recording if it already exists
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-c,\ \-\-command=<command>\f[]
|
||||
Specify command to record, defaults to \f[B]$SHELL\f[]
|
||||
.RS
|
||||
.RE
|
||||
\f[V]-c, --command=<command>\f[R]
|
||||
Specify command to record, defaults to \f[B]$SHELL\f[R]
|
||||
.TP
|
||||
.B \f[C]\-e,\ \-\-env=<var\-names>\f[]
|
||||
\f[V]-e, --env=<var-names>\f[R]
|
||||
List of environment variables to capture, defaults to
|
||||
\f[B]SHELL,TERM\f[]
|
||||
.RS
|
||||
.RE
|
||||
\f[B]SHELL,TERM\f[R]
|
||||
.TP
|
||||
.B \f[C]\-t,\ \-\-title=<title>\f[]
|
||||
\f[V]-t, --title=<title>\f[R]
|
||||
Specify the title of the asciicast
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[]
|
||||
Limit recorded terminal inactivity to max \f[C]<sec>\f[] seconds
|
||||
.RS
|
||||
.RE
|
||||
\f[V]-i, --idle-time-limit=<sec>\f[R]
|
||||
Limit recorded terminal inactivity to max \f[V]<sec>\f[R] seconds
|
||||
.TP
|
||||
.B \f[C]\-y,\ \-\-yes\f[]
|
||||
\f[V]--cols=<n>\f[R]
|
||||
Override terminal columns for recorded process
|
||||
.TP
|
||||
\f[V]--rows=<n>\f[R]
|
||||
Override terminal rows for recorded process
|
||||
.TP
|
||||
\f[V]-y, --yes\f[R]
|
||||
Answer \[lq]yes\[rq] to all prompts (e.g.\ upload confirmation)
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-q,\ \-\-quiet\f[]
|
||||
Be quiet, suppress all notices/warnings (implies \f[B]\-y\f[])
|
||||
.RS
|
||||
.RE
|
||||
\f[V]-q, --quiet\f[R]
|
||||
Be quiet, suppress all notices/warnings (implies \f[B]-y\f[R])
|
||||
.RE
|
||||
.PP
|
||||
Stdin recording allows for capturing of all characters typed in by the
|
||||
user in the currently recorded shell.
|
||||
This may be used by a player (e.g.
|
||||
asciinema\-player (https://github.com/asciinema/asciinema-player)) to
|
||||
asciinema-player (https://github.com/asciinema/asciinema-player)) to
|
||||
display pressed keys.
|
||||
Because it's basically a key\-logging (scoped to a single shell
|
||||
instance), it's disabled by default, and has to be explicitly enabled
|
||||
via \f[B]\[en]stdin\f[] option.
|
||||
.SS play <\f[I]filename\f[]>
|
||||
Because it\[cq]s basically a key-logging (scoped to a single shell
|
||||
instance), it\[cq]s disabled by default, and has to be explicitly
|
||||
enabled via \f[B]\[en]stdin\f[R] option.
|
||||
.SS play <\f[I]filename\f[R]>
|
||||
.PP
|
||||
Replay recorded asciicast in a terminal.
|
||||
.PP
|
||||
This command replays given asciicast (as recorded by \f[B]rec\f[]
|
||||
This command replays a given asciicast (as recorded by \f[B]rec\f[R]
|
||||
command) directly in your terminal.
|
||||
.PP
|
||||
Following keyboard shortcuts are available:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
Space\ \-\ toggle\ pause,
|
||||
\&.\ \-\ step\ through\ a\ recording\ a\ frame\ at\ a\ time\ (when\ paused),
|
||||
Ctrl+C\ \-\ exit.
|
||||
\f[]
|
||||
.fi
|
||||
The asciicast can be read from a file or from \f[I]\f[VI]stdin\f[I]\f[R]
|
||||
(`-'):
|
||||
.PP
|
||||
Playing from a local file:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ /path/to/asciicast.cast
|
||||
\f[]
|
||||
asciinema play /path/to/asciicast.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Playing from HTTP(S) URL:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ https://asciinema.org/a/22124.cast
|
||||
asciinema\ play\ http://example.com/demo.cast
|
||||
\f[]
|
||||
asciinema play https://asciinema.org/a/22124.cast
|
||||
asciinema play http://example.com/demo.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Playing from asciicast page URL (requires
|
||||
\f[C]<link\ rel="alternate"\ type="application/x\-asciicast"\ href="/my/ascii.cast">\f[]
|
||||
in page's HTML):
|
||||
\f[V]<link rel=\[dq]alternate\[dq] type=\[dq]application/x-asciicast\[dq] href=\[dq]/my/ascii.cast\[dq]>\f[R]
|
||||
in page\[cq]s HTML):
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ https://asciinema.org/a/22124
|
||||
asciinema\ play\ http://example.com/blog/post.html
|
||||
\f[]
|
||||
asciinema play https://asciinema.org/a/22124
|
||||
asciinema play http://example.com/blog/post.html
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Playing from stdin:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
cat\ /path/to/asciicast.cast\ |\ asciinema\ play\ \-
|
||||
ssh\ user\@host\ cat\ asciicast.cast\ |\ asciinema\ play\ \-
|
||||
\f[]
|
||||
cat /path/to/asciicast.cast | asciinema play -
|
||||
ssh user\[at]host cat asciicast.cast | asciinema play -
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Playing from IPFS:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
|
||||
\f[]
|
||||
asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.TP
|
||||
.B Available options:
|
||||
Available options:
|
||||
\
|
||||
.RS
|
||||
.TP
|
||||
.B \f[C]\-i,\ \-\-idle\-time\-limit=<sec>\f[]
|
||||
Limit replayed terminal inactivity to max \f[C]<sec>\f[] seconds
|
||||
.RS
|
||||
\f[V]-i, --idle-time-limit=<sec>\f[R]
|
||||
Limit replayed terminal inactivity to max \f[V]<sec>\f[R] seconds (can
|
||||
be fractional)
|
||||
.TP
|
||||
\f[V]-s, --speed=<factor>\f[R]
|
||||
Playback speed (can be fractional)
|
||||
.RE
|
||||
.TP
|
||||
.B \f[C]\-s,\ \-\-speed=<factor>\f[]
|
||||
Playback speed (can be fractional)
|
||||
While playing the following keyboard shortcuts are available:
|
||||
\
|
||||
.RS
|
||||
.TP
|
||||
\f[I]\f[VI]Space\f[I]\f[R]
|
||||
Toggle pause
|
||||
.TP
|
||||
\f[I]\f[VI].\f[I]\f[R]
|
||||
Step through a recording a frame at a time (when paused)
|
||||
.TP
|
||||
\f[I]\f[VI]Ctrl+C\f[I]\f[R]
|
||||
Exit
|
||||
.RE
|
||||
.RE
|
||||
.SS cat <\f[I]filename\f[]>
|
||||
.PP
|
||||
Recommendation: run `asciinema play' in a terminal of dimensions not
|
||||
smaller than the one used for recording as there\[cq]s no
|
||||
\[lq]transcoding\[rq] of control sequences for the new terminal size.
|
||||
.SS cat <\f[I]filename\f[R]>
|
||||
.PP
|
||||
Print full output of recorded asciicast to a terminal.
|
||||
.PP
|
||||
While \f[B]asciinema play \f[] replays the recorded session using timing
|
||||
information saved in the asciicast, \f[B]asciinema cat \f[] dumps the
|
||||
full output (including all escape sequences) to a terminal immediately.
|
||||
While \f[B]asciinema play \f[R] replays the recorded session using
|
||||
timing information saved in the asciicast, \f[B]asciinema cat \f[R]
|
||||
dumps the full output (including all escape sequences) to a terminal
|
||||
immediately.
|
||||
.PP
|
||||
\f[B]asciinema cat existing.cast >output.txt\f[] gives the same result
|
||||
as recording via \f[B]asciinema rec \-\-raw output.txt\f[].
|
||||
.SS upload
|
||||
\f[B]asciinema cat existing.cast >output.txt\f[R] gives the same result
|
||||
as recording via \f[B]asciinema rec --raw output.txt\f[R].
|
||||
.SS upload <\f[I]filename\f[R]>
|
||||
.PP
|
||||
Upload recorded asciicast to asciinema.org site.
|
||||
.PP
|
||||
This command uploads given asciicast (recorded by \f[B]rec\f[] command)
|
||||
This command uploads given asciicast (recorded by \f[B]rec\f[R] command)
|
||||
to asciinema.org, where it can be watched and shared.
|
||||
.PP
|
||||
\f[B]asciinema rec demo.cast\f[] + \f[B]asciinema play demo.cast\f[] +
|
||||
\f[B]asciinema upload demo.cast\f[] is a nice combo if you want to
|
||||
\f[B]asciinema rec demo.cast\f[R] + \f[B]asciinema play demo.cast\f[R] +
|
||||
\f[B]asciinema upload demo.cast\f[R] is a nice combo if you want to
|
||||
review an asciicast before publishing it on asciinema.org.
|
||||
.SS auth
|
||||
.PP
|
||||
Link your install ID with your asciinema.org user account.
|
||||
Link and manage your install ID with your asciinema.org user account.
|
||||
.PP
|
||||
If you want to manage your recordings (change title/theme, delete) at
|
||||
asciinema.org you need to link your \[lq]install ID\[rq] with
|
||||
asciinema.org you need to link your \[lq]install ID\[rq] with your
|
||||
asciinema.org user account.
|
||||
.PP
|
||||
This command displays the URL to open in a web browser to do that.
|
||||
@@ -228,16 +244,25 @@ You may be asked to log in first.
|
||||
Install ID is a random ID (UUID
|
||||
v4 (https://en.wikipedia.org/wiki/Universally_unique_identifier))
|
||||
generated locally when you run asciinema for the first time, and saved
|
||||
at \f[B]$HOME/.config/asciinema/install\-id\f[].
|
||||
It's purpose is to connect local machine with uploaded recordings, so
|
||||
they can later be associated with asciinema.org account.
|
||||
at \f[B]$HOME/.config/asciinema/install-id\f[R].
|
||||
It\[cq]s purpose is to connect local machine with uploaded recordings,
|
||||
so they can later be associated with asciinema.org account.
|
||||
This way we decouple uploading from account creation, allowing them to
|
||||
happen in any order.
|
||||
.PP
|
||||
Note: A new install ID is generated on each machine and system user
|
||||
account you use asciinema on, so in order to keep all recordings under a
|
||||
single asciinema.org account you need to run \f[B]asciinema auth\f[] on
|
||||
all of those machines.
|
||||
account you use asciinema on.
|
||||
So in order to keep all recordings under a single asciinema.org account
|
||||
you need to run \f[B]asciinema auth\f[R] on all of those machines.
|
||||
If you\[cq]re already logged in on asciinema.org website and you run
|
||||
`asciinema auth' from a new computer then this new device will be linked
|
||||
to your account.
|
||||
.PP
|
||||
While you CAN synchronize your config file (which keeps the API token)
|
||||
across all your machines so all use the same token, that\[cq]s not
|
||||
necessary.
|
||||
You can assign new tokens to your account from as many machines as you
|
||||
want.
|
||||
.PP
|
||||
Note: asciinema versions prior to 2.0 confusingly referred to install ID
|
||||
as \[lq]API token\[rq].
|
||||
@@ -247,28 +272,36 @@ Record your first session:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ rec\ first.cast
|
||||
\f[]
|
||||
asciinema rec first.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
End your session:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
exit
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Now replay it with double speed:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ \-s\ 2\ first.cast
|
||||
\f[]
|
||||
asciinema play -s 2 first.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Or with normal speed but with idle time limited to 2 seconds:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ play\ \-i\ 2\ first.cast
|
||||
\f[]
|
||||
asciinema play -i 2 first.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
You can pass \f[B]\-i 2\f[] to \f[B]asciinema rec\f[] as well, to set it
|
||||
permanently on a recording.
|
||||
You can pass \f[B]-i 2\f[R] to \f[B]asciinema rec\f[R] as well, to set
|
||||
it permanently on a recording.
|
||||
Idle time limiting makes the recordings much more interesting to watch,
|
||||
try it.
|
||||
.PP
|
||||
@@ -276,12 +309,12 @@ If you want to watch and share it on the web, upload it:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ upload\ first.cast
|
||||
\f[]
|
||||
asciinema upload first.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
The above uploads it to <https://asciinema.org>, which is a default
|
||||
asciinema\-server (<https://github.com/asciinema/asciinema-server>)
|
||||
asciinema-server (<https://github.com/asciinema/asciinema-server>)
|
||||
instance, and prints a secret link you can use to watch your recording
|
||||
in a web browser.
|
||||
.PP
|
||||
@@ -289,33 +322,79 @@ You can record and upload in one step by omitting the filename:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema\ rec
|
||||
\f[]
|
||||
asciinema rec
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
You'll be asked to confirm the upload when the recording is done, so
|
||||
You\[cq]ll be asked to confirm the upload when the recording is done, so
|
||||
nothing is sent anywhere without your consent.
|
||||
.SH ENVIRONMENT
|
||||
.SS Tricks
|
||||
.TP
|
||||
.B \f[B]ASCIINEMA_API_URL\f[]
|
||||
This variable allows overriding asciinema\-server URL (which defaults to
|
||||
https://asciinema.org) in case you're running your own asciinema\-server
|
||||
instance.
|
||||
Record slowly, play faster:
|
||||
First record a session where you can take your time to type slowly what
|
||||
you want to show in the recording:
|
||||
.RS
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema rec initial.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Then record the replay of `initial.cast' as `final.cast', but with five
|
||||
times the initially recorded speed, with all pauses capped to two
|
||||
seconds and with a title set as \[lq]My fancy title\[rq]::
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
asciinema rec -c \[dq]asciinema play -s 5 -i 2 initial.cast\[dq] -t \[dq]My fancy title\[dq] final.cast
|
||||
\f[R]
|
||||
.fi
|
||||
.RE
|
||||
.TP
|
||||
.B \f[B]ASCIINEMA_CONFIG_HOME\f[]
|
||||
Play from \f[I]\f[VI]stdin\f[I]\f[R]:
|
||||
\
|
||||
.RS
|
||||
.PP
|
||||
cat /path/to/asciicast.json | asciinema play -
|
||||
.RE
|
||||
.TP
|
||||
Play file from remote host accessible with SSH:
|
||||
\
|
||||
.RS
|
||||
.PP
|
||||
ssh user\[at]host cat /path/to/asciicat.json | asciinema play -
|
||||
.RE
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
\f[B]ASCIINEMA_API_URL\f[R]
|
||||
This variable allows overriding asciinema-server URL (which defaults to
|
||||
https://asciinema.org) in case you\[cq]re running your own
|
||||
asciinema-server instance.
|
||||
.TP
|
||||
\f[B]ASCIINEMA_CONFIG_HOME\f[R]
|
||||
This variable allows overriding config directory location.
|
||||
Default location is $XDG_CONFIG_HOME/asciinema (when $XDG_CONFIG_HOME is
|
||||
set) or $HOME/.config/asciinema.
|
||||
.RS
|
||||
.RE
|
||||
.SH BUGS
|
||||
.PP
|
||||
See GitHub Issues: <https://github.com/asciinema/asciinema/issues>
|
||||
.SH MORE RESOURCES
|
||||
.PP
|
||||
More documentation is available on the asciicast.org website and its
|
||||
GitHub wiki:
|
||||
.IP \[bu] 2
|
||||
Web: asciinema.org/docs/ (https://asciinema.org/docs/)
|
||||
.IP \[bu] 2
|
||||
Wiki:
|
||||
github.com/asciinema/asciinema/wiki (https://github.com/asciinema/asciinema/wiki)
|
||||
.IP \[bu] 2
|
||||
IRC: Channel on Libera.Chat (https://web.libera.chat/gamja/#asciinema)
|
||||
.IP \[bu] 2
|
||||
Twitter: \[at]asciinema (https://twitter.com/asciinema)
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
asciinema's lead developer is Marcin Kulik.
|
||||
asciinema\[cq]s lead developer is Marcin Kulik.
|
||||
.PP
|
||||
For a list of all contributors look here:
|
||||
<https://github.com/asciinema/asciinema/contributors>
|
||||
|
||||
@@ -43,9 +43,9 @@ command (process) that is recorded can be specified with **-c** option (see
|
||||
below), and defaults to **$SHELL** which is what you want in most cases.
|
||||
|
||||
You can temporarily pause the capture of your terminal by pressing
|
||||
<kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during
|
||||
<kbd>Ctrl+\\</kbd>. This is useful when you want to execute some commands during
|
||||
the recording session that should not be captured (e.g. pasting secrets). Resume
|
||||
by pressing <kbd>Ctrl+\</kbd> again. When pausing desktop notification is
|
||||
by pressing <kbd>Ctrl+\\</kbd> again. When pausing desktop notification is
|
||||
displayed so you're sure the sensitive output won't be captured in the
|
||||
recording.
|
||||
|
||||
@@ -95,6 +95,12 @@ Available options:
|
||||
`-i, --idle-time-limit=<sec>`
|
||||
: Limit recorded terminal inactivity to max `<sec>` seconds
|
||||
|
||||
`--cols=<n>`
|
||||
: Override terminal columns for recorded process
|
||||
|
||||
`--rows=<n>`
|
||||
: Override terminal rows for recorded process
|
||||
|
||||
`-y, --yes`
|
||||
: Answer "yes" to all prompts (e.g. upload confirmation)
|
||||
|
||||
@@ -325,6 +331,10 @@ More documentation is available on the asciicast.org website and its GitHub wiki
|
||||
|
||||
* Web: [asciinema.org/docs/](https://asciinema.org/docs/)
|
||||
* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki)
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
* IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema)
|
||||
>>>>>>> develop
|
||||
* Twitter: [@asciinema](https://twitter.com/asciinema)
|
||||
|
||||
|
||||
@@ -336,4 +346,3 @@ asciinema's lead developer is Marcin Kulik.
|
||||
For a list of all contributors look here: <https://github.com/asciinema/asciinema/contributors>
|
||||
|
||||
This Manual Page was written by Marcin Kulik with help from Kurt Pfeifle.
|
||||
|
||||
|
||||
38
pyproject.toml
Normal file
38
pyproject.toml
Normal 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
|
||||
55
setup.cfg
55
setup.cfg
@@ -1,6 +1,59 @@
|
||||
[metadata]
|
||||
description-file = README.md
|
||||
name = asciinema
|
||||
version = 2.2.0
|
||||
author = Marcin Kulik
|
||||
author_email = m@ku1ik.com
|
||||
url = https://asciinema.org
|
||||
download_url =
|
||||
https://github.com/asciinema/asciinema/archive/v%(version)s.tar.gz
|
||||
description = Terminal session recorder
|
||||
description_file = README.md
|
||||
license = GNU GPLv3
|
||||
license_file = LICENSE
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown; charset=UTF-8
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Environment :: Console
|
||||
Intended Audience :: Developers
|
||||
Intended Audience :: System Administrators
|
||||
License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
||||
Natural Language :: English
|
||||
Programming Language :: Python
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Topic :: System :: Shells
|
||||
Topic :: Terminals
|
||||
Topic :: Utilities
|
||||
|
||||
[options]
|
||||
include_package_data = True
|
||||
packages =
|
||||
asciinema
|
||||
asciinema.asciicast
|
||||
asciinema.commands
|
||||
install_requires =
|
||||
|
||||
[options.package_data]
|
||||
asciinema = data/*.png
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
asciinema = asciinema.__main__:main
|
||||
|
||||
[options.data_files]
|
||||
share/doc/asciinema =
|
||||
CHANGELOG.md
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
README.md
|
||||
doc/asciicast-v1.md
|
||||
doc/asciicast-v2.md
|
||||
share/man/man1 =
|
||||
man/asciinema.1
|
||||
|
||||
[pycodestyle]
|
||||
ignore = E501,E402,E722
|
||||
|
||||
58
setup.py
58
setup.py
@@ -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'
|
||||
],
|
||||
)
|
||||
@@ -1,24 +1,28 @@
|
||||
from ..test_helper import Test
|
||||
import asciinema.asciicast.v2 as v2
|
||||
import tempfile
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from asciinema.asciicast import v2
|
||||
|
||||
from ..test_helper import Test
|
||||
|
||||
|
||||
class TestWriter(Test):
|
||||
|
||||
def test_writing(self):
|
||||
@staticmethod
|
||||
def test_writing() -> None:
|
||||
_file, path = tempfile.mkstemp()
|
||||
|
||||
with v2.writer(path, width=80, height=24) as w:
|
||||
w.write_stdout(1, 'x') # ensure it supports both str and bytes
|
||||
w.write_stdout(2, bytes.fromhex('78 c5 bc c3 b3 c5'))
|
||||
w.write_stdout(3, bytes.fromhex('82 c4 87'))
|
||||
w.write_stdout(4, bytes.fromhex('78 78'))
|
||||
w.write_stdout(1, "x") # ensure it supports both str and bytes
|
||||
w.write_stdout(2, bytes.fromhex("78 c5 bc c3 b3 c5"))
|
||||
w.write_stdout(3, bytes.fromhex("82 c4 87"))
|
||||
w.write_stdout(4, bytes.fromhex("78 78"))
|
||||
|
||||
with open(path, 'r') as f:
|
||||
lines = list(map(json.loads, f.read().strip().split('\n')))
|
||||
assert lines == [{"version": 2, "width": 80, "height": 24},
|
||||
[1, "o", "x"],
|
||||
[2, "o", "xżó"],
|
||||
[3, "o", "łć"],
|
||||
[4, "o", "xx"]], 'got:\n\n%s' % lines
|
||||
with open(path, "rt", encoding="utf_8") as f:
|
||||
lines = list(map(json.loads, f.read().strip().split("\n")))
|
||||
assert lines == [
|
||||
{"version": 2, "width": 80, "height": 24},
|
||||
[1, "o", "x"],
|
||||
[2, "o", "xżó"],
|
||||
[3, "o", "łć"],
|
||||
[4, "o", "xx"],
|
||||
], f"got:\n\n{lines}"
|
||||
|
||||
@@ -1,208 +1,218 @@
|
||||
from nose.tools import assert_equal, assert_raises
|
||||
|
||||
import os
|
||||
import os.path as path
|
||||
import tempfile
|
||||
import re
|
||||
import tempfile
|
||||
from os import path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import asciinema.config as cfg
|
||||
from asciinema.config import Config
|
||||
|
||||
|
||||
def create_config(content=None, env={}):
|
||||
dir = tempfile.mkdtemp()
|
||||
def create_config(
|
||||
content: Optional[str] = None, env: Optional[Dict[str, str]] = None
|
||||
) -> Config:
|
||||
# avoid redefining `dir` builtin
|
||||
dir_ = tempfile.mkdtemp()
|
||||
|
||||
if content:
|
||||
path = dir + '/config'
|
||||
with open(path, 'w') as f:
|
||||
# avoid redefining `os.path`
|
||||
path_ = f"{dir_}/config"
|
||||
with open(path_, "wt", encoding="utf_8") as f:
|
||||
f.write(content)
|
||||
|
||||
return cfg.Config(dir, env)
|
||||
return cfg.Config(dir_, env)
|
||||
|
||||
|
||||
def read_install_id(install_id_path):
|
||||
with open(install_id_path, 'r') as f:
|
||||
def read_install_id(install_id_path: str) -> str:
|
||||
with open(install_id_path, "rt", encoding="utf_8") as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def test_upgrade_no_config_file():
|
||||
def test_upgrade_no_config_file() -> None:
|
||||
config = create_config()
|
||||
config.upgrade()
|
||||
install_id = read_install_id(config.install_id_path)
|
||||
|
||||
assert re.match('^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}', install_id)
|
||||
assert_equal(install_id, config.install_id)
|
||||
assert re.match("^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}", install_id)
|
||||
assert install_id == config.install_id
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
# it must not change after another upgrade
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), install_id)
|
||||
assert read_install_id(config.install_id_path) == install_id
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_api_token():
|
||||
def test_upgrade_config_file_with_api_token() -> None:
|
||||
config = create_config("[api]\ntoken = foo-bar-baz")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
assert config.install_id == "foo-bar-baz"
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_api_token_and_more():
|
||||
config = create_config("[api]\ntoken = foo-bar-baz\nurl = http://example.com")
|
||||
def test_upgrade_config_file_with_api_token_and_more() -> None:
|
||||
config = create_config(
|
||||
"[api]\ntoken = foo-bar-baz\nurl = http://example.com"
|
||||
)
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert_equal(config.api_url, 'http://example.com')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
assert config.install_id == "foo-bar-baz"
|
||||
assert config.api_url == "http://example.com"
|
||||
assert path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_user_token():
|
||||
def test_upgrade_config_file_with_user_token() -> None:
|
||||
config = create_config("[user]\ntoken = foo-bar-baz")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
assert config.install_id == "foo-bar-baz"
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_user_token_and_more():
|
||||
config = create_config("[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com")
|
||||
def test_upgrade_config_file_with_user_token_and_more() -> None:
|
||||
config = create_config(
|
||||
"[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com"
|
||||
)
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert_equal(config.api_url, 'http://example.com')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
assert config.install_id == "foo-bar-baz"
|
||||
assert config.api_url == "http://example.com"
|
||||
assert path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert read_install_id(config.install_id_path) == "foo-bar-baz"
|
||||
|
||||
|
||||
def test_default_api_url():
|
||||
config = create_config('')
|
||||
assert_equal('https://asciinema.org', config.api_url)
|
||||
def test_default_api_url() -> None:
|
||||
config = create_config("")
|
||||
assert config.api_url == "https://asciinema.org"
|
||||
|
||||
|
||||
def test_default_record_stdin():
|
||||
config = create_config('')
|
||||
assert_equal(False, config.record_stdin)
|
||||
def test_default_record_stdin() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_stdin is False
|
||||
|
||||
|
||||
def test_default_record_command():
|
||||
config = create_config('')
|
||||
assert_equal(None, config.record_command)
|
||||
def test_default_record_command() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_command is None
|
||||
|
||||
|
||||
def test_default_record_env():
|
||||
config = create_config('')
|
||||
assert_equal('SHELL,TERM', config.record_env)
|
||||
def test_default_record_env() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_env == "SHELL,TERM"
|
||||
|
||||
|
||||
def test_default_record_idle_time_limit():
|
||||
config = create_config('')
|
||||
assert_equal(None, config.record_idle_time_limit)
|
||||
def test_default_record_idle_time_limit() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_idle_time_limit is None
|
||||
|
||||
|
||||
def test_default_record_yes():
|
||||
config = create_config('')
|
||||
assert_equal(False, config.record_yes)
|
||||
def test_default_record_yes() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_yes is False
|
||||
|
||||
|
||||
def test_default_record_quiet():
|
||||
config = create_config('')
|
||||
assert_equal(False, config.record_quiet)
|
||||
def test_default_record_quiet() -> None:
|
||||
config = create_config("")
|
||||
assert config.record_quiet is False
|
||||
|
||||
|
||||
def test_default_play_idle_time_limit():
|
||||
config = create_config('')
|
||||
assert_equal(None, config.play_idle_time_limit)
|
||||
def test_default_play_idle_time_limit() -> None:
|
||||
config = create_config("")
|
||||
assert config.play_idle_time_limit is None
|
||||
|
||||
|
||||
def test_api_url():
|
||||
def test_api_url() -> None:
|
||||
config = create_config("[api]\nurl = http://the/url")
|
||||
assert_equal('http://the/url', config.api_url)
|
||||
assert config.api_url == "http://the/url"
|
||||
|
||||
|
||||
def test_api_url_when_override_set():
|
||||
config = create_config("[api]\nurl = http://the/url", {
|
||||
'ASCIINEMA_API_URL': 'http://the/url2'})
|
||||
assert_equal('http://the/url2', config.api_url)
|
||||
def test_api_url_when_override_set() -> None:
|
||||
config = create_config(
|
||||
"[api]\nurl = http://the/url", {"ASCIINEMA_API_URL": "http://the/url2"}
|
||||
)
|
||||
assert config.api_url == "http://the/url2"
|
||||
|
||||
|
||||
def test_record_command():
|
||||
command = 'bash -l'
|
||||
config = create_config("[record]\ncommand = %s" % command)
|
||||
assert_equal(command, config.record_command)
|
||||
def test_record_command() -> None:
|
||||
command = "bash -l"
|
||||
config = create_config(f"[record]\ncommand = {command}")
|
||||
assert config.record_command == command
|
||||
|
||||
|
||||
def test_record_stdin():
|
||||
def test_record_stdin() -> None:
|
||||
config = create_config("[record]\nstdin = yes")
|
||||
assert_equal(True, config.record_stdin)
|
||||
assert config.record_stdin is True
|
||||
|
||||
|
||||
def test_record_env():
|
||||
def test_record_env() -> None:
|
||||
config = create_config("[record]\nenv = FOO,BAR")
|
||||
assert_equal('FOO,BAR', config.record_env)
|
||||
assert config.record_env == "FOO,BAR"
|
||||
|
||||
|
||||
def test_record_idle_time_limit():
|
||||
def test_record_idle_time_limit() -> None:
|
||||
config = create_config("[record]\nidle_time_limit = 2.35")
|
||||
assert_equal(2.35, config.record_idle_time_limit)
|
||||
assert config.record_idle_time_limit == 2.35
|
||||
|
||||
config = create_config("[record]\nmaxwait = 2.35")
|
||||
assert_equal(2.35, config.record_idle_time_limit)
|
||||
assert config.record_idle_time_limit == 2.35
|
||||
|
||||
|
||||
def test_record_yes():
|
||||
yes = 'yes'
|
||||
config = create_config("[record]\nyes = %s" % yes)
|
||||
assert_equal(True, config.record_yes)
|
||||
def test_record_yes() -> None:
|
||||
yes = "yes"
|
||||
config = create_config(f"[record]\nyes = {yes}")
|
||||
assert config.record_yes is True
|
||||
|
||||
|
||||
def test_record_quiet():
|
||||
quiet = 'yes'
|
||||
config = create_config("[record]\nquiet = %s" % quiet)
|
||||
assert_equal(True, config.record_quiet)
|
||||
def test_record_quiet() -> None:
|
||||
quiet = "yes"
|
||||
config = create_config(f"[record]\nquiet = {quiet}")
|
||||
assert config.record_quiet is True
|
||||
|
||||
|
||||
def test_play_idle_time_limit():
|
||||
def test_play_idle_time_limit() -> None:
|
||||
config = create_config("[play]\nidle_time_limit = 2.35")
|
||||
assert_equal(2.35, config.play_idle_time_limit)
|
||||
assert config.play_idle_time_limit == 2.35
|
||||
|
||||
config = create_config("[play]\nmaxwait = 2.35")
|
||||
assert_equal(2.35, config.play_idle_time_limit)
|
||||
assert config.play_idle_time_limit == 2.35
|
||||
|
||||
|
||||
def test_notifications_enabled():
|
||||
config = create_config('')
|
||||
assert_equal(True, config.notifications_enabled)
|
||||
def test_notifications_enabled() -> None:
|
||||
config = create_config("")
|
||||
assert config.notifications_enabled is True
|
||||
|
||||
config = create_config("[notifications]\nenabled = yes")
|
||||
assert_equal(True, config.notifications_enabled)
|
||||
assert config.notifications_enabled is True
|
||||
|
||||
config = create_config("[notifications]\nenabled = no")
|
||||
assert_equal(False, config.notifications_enabled)
|
||||
assert config.notifications_enabled is False
|
||||
|
||||
|
||||
def test_notifications_command():
|
||||
config = create_config('')
|
||||
assert_equal(None, config.notifications_command)
|
||||
def test_notifications_command() -> None:
|
||||
config = create_config("")
|
||||
assert config.notifications_command is None
|
||||
|
||||
config = create_config('[notifications]\ncommand = tmux display-message "$TEXT"')
|
||||
assert_equal('tmux display-message "$TEXT"', config.notifications_command)
|
||||
config = create_config(
|
||||
'[notifications]\ncommand = tmux display-message "$TEXT"'
|
||||
)
|
||||
assert config.notifications_command == 'tmux display-message "$TEXT"'
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
path_to_self="${BASH_SOURCE[0]}"
|
||||
tests_dir="$(cd "$(dirname "$path_to_self")" && pwd)"
|
||||
readonly DISTROS=(
|
||||
'arch'
|
||||
'alpine'
|
||||
'centos'
|
||||
'debian'
|
||||
'fedora'
|
||||
'ubuntu'
|
||||
)
|
||||
|
||||
test() {
|
||||
printf "\e[1;32mTesting on $1...\e[0m\n"
|
||||
echo
|
||||
readonly DOCKER='docker'
|
||||
|
||||
docker build -t asciinema/asciinema:$1 -f tests/distros/Dockerfile.$1 .
|
||||
docker run --rm -ti asciinema/asciinema:$1 tests/integration.sh
|
||||
# do not redefine builtin `test`
|
||||
test_() {
|
||||
local -r tag="${1}"
|
||||
|
||||
local -ra docker_opts=(
|
||||
"--tag=asciinema/asciinema:${tag}"
|
||||
"--file=tests/distros/Dockerfile.${tag}"
|
||||
)
|
||||
|
||||
printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}"
|
||||
|
||||
# shellcheck disable=SC2068
|
||||
"${DOCKER}" build ${docker_opts[@]} .
|
||||
|
||||
"${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh
|
||||
}
|
||||
|
||||
test ubuntu
|
||||
test debian
|
||||
test fedora
|
||||
test centos
|
||||
|
||||
echo
|
||||
printf "\e[1;32mAll tests passed.\e[0m\n"
|
||||
for distro in "${DISTROS[@]}"; do
|
||||
test_ "${distro}"
|
||||
done
|
||||
|
||||
printf "\n\e[1;32mAll tests passed.\e[0m\n"
|
||||
|
||||
19
tests/distros/Dockerfile.alpine
Normal file
19
tests/distros/Dockerfile.alpine
Normal 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
|
||||
22
tests/distros/Dockerfile.arch
Normal file
22
tests/distros/Dockerfile.arch
Normal 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
|
||||
@@ -1,10 +1,18 @@
|
||||
FROM centos:7
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/centos:7
|
||||
|
||||
RUN yum install -y epel-release && yum install -y make python36 && yum clean all
|
||||
|
||||
RUN yum install -y epel-release
|
||||
RUN yum install -y python34
|
||||
WORKDIR /usr/src/app
|
||||
COPY asciinema asciinema
|
||||
COPY tests tests
|
||||
ENV LANG en_US.utf8
|
||||
ENV SHELL /bin/bash
|
||||
ENV USER docker
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
FROM debian:jessie
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/debian:bullseye
|
||||
|
||||
ENV DEBIAN_FRONTENT="noninteractive"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
make \
|
||||
procps \
|
||||
python3 \
|
||||
&& localedef \
|
||||
-i en_US \
|
||||
-c \
|
||||
-f UTF-8 \
|
||||
-A /usr/share/locale/locale.alias \
|
||||
en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
python3
|
||||
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
|
||||
WORKDIR /usr/src/app
|
||||
COPY asciinema asciinema
|
||||
COPY tests tests
|
||||
ENV LANG en_US.utf8
|
||||
ENV SHELL /bin/bash
|
||||
ENV USER docker
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENV SHELL="/bin/bash"
|
||||
|
||||
# vim:ft=dockerfile
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
FROM fedora:26
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
# https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921
|
||||
# https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html
|
||||
FROM registry.fedoraproject.org/fedora:34
|
||||
|
||||
RUN dnf install -y make python3 procps && dnf clean all
|
||||
|
||||
RUN dnf install -y python3 procps
|
||||
WORKDIR /usr/src/app
|
||||
COPY asciinema asciinema
|
||||
COPY tests tests
|
||||
ENV LANG en_US.utf8
|
||||
ENV SHELL /bin/bash
|
||||
ENV USER docker
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
ENV SHELL="/bin/bash"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
# vim:ft=dockerfile
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
FROM ubuntu:16.04
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/ubuntu:20.04
|
||||
|
||||
ENV DEBIAN_FRONTENT="noninteractive"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
make \
|
||||
python3 \
|
||||
&& localedef \
|
||||
-i en_US \
|
||||
-c \
|
||||
-f UTF-8 \
|
||||
-A /usr/share/locale/locale.alias \
|
||||
en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
python3
|
||||
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
|
||||
WORKDIR /usr/src/app
|
||||
COPY asciinema asciinema
|
||||
COPY tests tests
|
||||
ENV LANG en_US.utf8
|
||||
ENV SHELL /bin/bash
|
||||
ENV USER docker
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
set -eExuo pipefail
|
||||
|
||||
if ! type "pkill" >/dev/null 2>&1; then
|
||||
echo "error: pkill not installed"
|
||||
if ! command -v "pkill" >/dev/null 2>&1; then
|
||||
printf "error: pkill not installed\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -V
|
||||
|
||||
export ASCIINEMA_CONFIG_HOME=`mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home`
|
||||
TMP_DATA_DIR=`mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir`
|
||||
trap "rm -rf $ASCIINEMA_CONFIG_HOME $TMP_DATA_DIR" EXIT
|
||||
ASCIINEMA_CONFIG_HOME="$(
|
||||
mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
|
||||
)"
|
||||
|
||||
function asciinema() {
|
||||
python3 -m asciinema "$@"
|
||||
export ASCIINEMA_CONFIG_HOME
|
||||
|
||||
TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)"
|
||||
|
||||
trap 'rm -rf ${ASCIINEMA_CONFIG_HOME} ${TMP_DATA_DIR}' EXIT
|
||||
|
||||
asciinema() {
|
||||
python3 -m asciinema "${@}"
|
||||
}
|
||||
|
||||
## test help message
|
||||
@@ -35,50 +40,56 @@ asciinema auth
|
||||
# asciicast v1
|
||||
asciinema play -s 5 tests/demo.json
|
||||
asciinema play -s 5 -i 0.2 tests/demo.json
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.json | asciinema play -s 5 -
|
||||
|
||||
# asciicast v2
|
||||
asciinema play -s 5 tests/demo.cast
|
||||
asciinema play -s 5 -i 0.2 tests/demo.cast
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.cast | asciinema play -s 5 -
|
||||
|
||||
## test cat command
|
||||
|
||||
# asciicast v1
|
||||
asciinema cat tests/demo.json
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.json | asciinema cat -
|
||||
|
||||
# asciicast v2
|
||||
asciinema cat tests/demo.cast
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.cast | asciinema cat -
|
||||
|
||||
## test rec command
|
||||
|
||||
# normal program
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/1a.cast"
|
||||
grep '"o",' "$TMP_DATA_DIR/1a.cast"
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/1a.cast"
|
||||
|
||||
# very quickly exiting program
|
||||
# https://github.com/asciinema/asciinema/issues/246
|
||||
# asciinema rec -c who "$TMP_DATA_DIR/1b.cast"
|
||||
# grep '"o",' "$TMP_DATA_DIR/1b.cast"
|
||||
asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/1b.cast"
|
||||
|
||||
# signal handling
|
||||
bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/2.cast"
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast"
|
||||
|
||||
bash -c "sleep 1; pkill -n -f 'bash -c echo t3st'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/3.cast"
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/3.cast"
|
||||
|
||||
bash -c "sleep 1; pkill -9 -n -f 'bash -c echo t3st'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "$TMP_DATA_DIR/4.cast"
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/4.cast"
|
||||
|
||||
# with stdin recording
|
||||
asciinema rec --stdin -c 'bash -c "echo t3st; sleep 1; echo ok"' "$TMP_DATA_DIR/5.cast"
|
||||
echo "ls" | asciinema rec --stdin -c 'bash -c "sleep 1"' "${TMP_DATA_DIR}/5.cast"
|
||||
cat "${TMP_DATA_DIR}/5.cast"
|
||||
grep '"i", "ls\\n"' "${TMP_DATA_DIR}/5.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/5.cast"
|
||||
|
||||
# raw output recording
|
||||
asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "$TMP_DATA_DIR/6.raw"
|
||||
asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/6.raw"
|
||||
|
||||
# appending to existing recording
|
||||
asciinema rec -c 'echo allright!; sleep 0.1' "$TMP_DATA_DIR/7.cast"
|
||||
asciinema rec --append -c uptime "$TMP_DATA_DIR/7.cast"
|
||||
asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast"
|
||||
asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast"
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
import os
|
||||
import pty
|
||||
from typing import Any, List, Union
|
||||
|
||||
import asciinema.pty_
|
||||
|
||||
from nose.tools import assert_equal
|
||||
from .test_helper import Test
|
||||
|
||||
import asciinema.pty
|
||||
|
||||
class Writer:
|
||||
def __init__(self) -> None:
|
||||
self.data: List[Union[float, str]] = []
|
||||
|
||||
class FakeStdout:
|
||||
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
|
||||
def write_stdout(self, ts, data):
|
||||
def write_stdout(self, _ts: float, data: Any) -> None:
|
||||
self.data.append(data)
|
||||
|
||||
def write_stdin(self, ts, data):
|
||||
pass
|
||||
def write_stdin(self, ts: float, data: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TestRecord(Test):
|
||||
|
||||
def setUp(self):
|
||||
def setUp(self) -> None:
|
||||
self.real_os_write = os.write
|
||||
os.write = self.os_write
|
||||
os.write = self.os_write # type: ignore
|
||||
|
||||
def tearDown(self):
|
||||
def tearDown(self) -> None:
|
||||
os.write = self.real_os_write
|
||||
|
||||
def os_write(self, fd, data):
|
||||
def os_write(self, fd: int, data: Any) -> None:
|
||||
if fd != pty.STDOUT_FILENO:
|
||||
self.real_os_write(fd, data)
|
||||
|
||||
def test_record_command_writes_to_stdout(self):
|
||||
output = FakeStdout()
|
||||
@staticmethod
|
||||
def test_record_command_writes_to_stdout() -> None:
|
||||
writer = Writer()
|
||||
|
||||
command = ['python3', '-c', "import sys; import time; sys.stdout.write(\'foo\'); sys.stdout.flush(); time.sleep(0.01); sys.stdout.write(\'bar\')"]
|
||||
asciinema.pty.record(command, output)
|
||||
command = [
|
||||
"python3",
|
||||
"-c",
|
||||
(
|
||||
"import sys"
|
||||
"; import time"
|
||||
"; sys.stdout.write('foo')"
|
||||
"; sys.stdout.flush()"
|
||||
"; time.sleep(0.01)"
|
||||
"; sys.stdout.write('bar')"
|
||||
),
|
||||
]
|
||||
|
||||
assert_equal([b'foo', b'bar'], output.data)
|
||||
asciinema.pty_.record(
|
||||
command, {}, writer, lambda: (80, 24), lambda s: None, {}
|
||||
)
|
||||
|
||||
assert writer.data == [b"foo", b"bar"]
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
import sys
|
||||
try:
|
||||
from StringIO import StringIO
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from codecs import StreamReader
|
||||
from io import StringIO
|
||||
from typing import Optional, TextIO, Union
|
||||
|
||||
|
||||
stdout = None
|
||||
|
||||
|
||||
def assert_printed(expected):
|
||||
success = expected in stdout.getvalue()
|
||||
assert success, 'expected text "%s" not printed' % expected
|
||||
|
||||
|
||||
def assert_not_printed(expected):
|
||||
success = expected not in stdout.getvalue()
|
||||
assert success, 'not expected text "%s" printed' % expected
|
||||
stdout: Optional[Union[TextIO, StreamReader]] = None
|
||||
|
||||
|
||||
class Test:
|
||||
|
||||
def setUp(self):
|
||||
global stdout
|
||||
def setUp(self) -> None:
|
||||
global stdout # pylint: disable=global-statement
|
||||
self.real_stdout = sys.stdout
|
||||
sys.stdout = stdout = StringIO()
|
||||
|
||||
def tearDown(self):
|
||||
def tearDown(self) -> None:
|
||||
sys.stdout = self.real_stdout
|
||||
|
||||
|
||||
class FakeClock:
|
||||
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
self.n = 0
|
||||
|
||||
def time(self):
|
||||
value = self.values[self.n]
|
||||
self.n += 1
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class FakeAsciicast:
|
||||
|
||||
def __init__(self, cmd=None, title=None, stdout=None, meta_data=None):
|
||||
self.cmd = cmd
|
||||
self.title = title
|
||||
self.stdout = stdout
|
||||
self.meta_data = meta_data or {}
|
||||
|
||||
Reference in New Issue
Block a user