From e07e32d02622937704abf0d890db21665c35494e Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 24 Oct 2021 18:58:09 -0400 Subject: [PATCH 001/121] Refactor Dockerfile: consolidate instructions --- Dockerfile | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index cea3fe2..a52a5c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,37 @@ -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-setuptools \ + && localedef \ + -i en_US \ + -c \ + -f UTF-8 \ + -A /usr/share/locale/locale.alias en_US.UTF-8 + +COPY setup.cfg setup.py *.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/ + WORKDIR /usr/src/app + RUN python3 setup.py install -ENV LANG en_US.utf8 -ENV SHELL /bin/bash -ENV USER docker + +ENV LANG="en_US.utf8" +ENV SHELL="/bin/bash" +ENV USER="docker" + WORKDIR /root + ENTRYPOINT ["/usr/local/bin/asciinema"] +CMD ["--help"] + +# vim:ft=dockerfile From eaba2f5d9e3ce0028495b0cca32bf5d177c62b3a Mon Sep 17 00:00:00 2001 From: Michael Palimaka Date: Tue, 26 Oct 2021 19:13:33 +1100 Subject: [PATCH 002/121] setup.cfg: fix deprecated key usage --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index bbd4d8d..17afd12 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -description-file = README.md +description_file = README.md license_file = LICENSE [pycodestyle] From 7bbc89b65ca7a24409d83fd25eb5f9a10a2ccff3 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 29 Oct 2021 23:00:43 -0400 Subject: [PATCH 003/121] [ci] Format asciinema workflow; add Python 3.10 --- .github/workflows/asciinema.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index ac35dc5..251b7ef 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -1,6 +1,8 @@ name: build -on: [push, pull_request] +on: + - push + - pull_request jobs: # Code style checks @@ -13,14 +15,15 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.9" - name: Install dependencies - run: pip install pycodestyle twine setuptools>=38.6.0 cmarkgfm + 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=E501,E402,E722 "{}" \+ - name: Run twine run: | - python setup.py --quiet sdist + python3 -m build twine check dist/* # Asciinema checks asciinema: @@ -28,7 +31,12 @@ jobs: 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: @@ -39,6 +47,6 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install dependencies - run: pip install nose + run: pip install pytest - name: Run Asciinema tests run: script -e -c make test From a89cc2d6f963b4284371dffa355bcbff1c4bd5b5 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 29 Oct 2021 23:04:09 -0400 Subject: [PATCH 004/121] [test] Shellcheck tests/*.sh --- tests/distros.sh | 30 +++++++++++++++------------ tests/integration.sh | 49 ++++++++++++++++++++++++++------------------ 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/tests/distros.sh b/tests/distros.sh index deb45c6..48f70bc 100755 --- a/tests/distros.sh +++ b/tests/distros.sh @@ -2,21 +2,25 @@ set -e -path_to_self="${BASH_SOURCE[0]}" -tests_dir="$(cd "$(dirname "$path_to_self")" && pwd)" -test() { - printf "\e[1;32mTesting on $1...\e[0m\n" - echo +# do not redefine builtin `test` +test_() { + local -r tag="${1}" - docker build -t asciinema/asciinema:$1 -f tests/distros/Dockerfile.$1 . - docker run --rm -ti asciinema/asciinema:$1 tests/integration.sh + printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}" + + docker build \ + --tag="asciinema/asciinema:${tag}" \ + --file="tests/distros/Dockerfile.${tag}" \ + . + + docker run --rm -i "asciinema/asciinema:${tag}" tests/integration.sh } -test ubuntu -test debian -test fedora -test centos -echo -printf "\e[1;32mAll tests passed.\e[0m\n" +test_ ubuntu +test_ debian +test_ fedora +test_ centos + +printf "\n\e[1;32mAll tests passed.\e[0m\n" diff --git a/tests/integration.sh b/tests/integration.sh index 7630c11..1d0cb11 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1,21 +1,26 @@ #!/usr/bin/env bash -set -e -set -x +set -ex -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,54 @@ 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 who "${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" +asciinema rec --stdin -c 'bash -c "echo t3st; sleep 1; echo ok"' "${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" From 2bfb38c1f3efe8493a3d6608b55a6fa41c513085 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 29 Oct 2021 23:29:42 -0400 Subject: [PATCH 005/121] [build] Refactor Makefile --- Makefile | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 747b7c2..ce13182 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,48 @@ -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 +.PHONY: test +test: test.unit test.integration -test-unit: +.PHONY: test.unit +test.unit: nosetests -test-integration: +.PHONY: test.integration +test.integration: tests/integration.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: .pip +.pip: + python3 -m pip install --user --upgrade --quiet build twine + +build: + python3 -m build . + +.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 From fe5433e61e24260fe4f95d1c0589d5c60b74575d Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 00:37:44 -0400 Subject: [PATCH 006/121] Convert setup.py to setup.cfg, pyproject.toml https://www.python.org/dev/peps/pep-0518/ --- pyproject.toml | 3 +++ setup.cfg | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 58 -------------------------------------------------- 3 files changed, 61 insertions(+), 58 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index 17afd12..dcbc837 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,64 @@ [metadata] +name = asciinema +version = 2.0.2 +# does not work with interpolation in `download_url` below +#version = attr: asciinema.__version__ +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 + 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 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + 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 diff --git a/setup.py b/setup.py deleted file mode 100644 index e7d3ef3..0000000 --- a/setup.py +++ /dev/null @@ -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' - ], -) From 07b29ee2b51e227c59ebd6ed4a0ce387625a5fee Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 12:16:09 -0400 Subject: [PATCH 007/121] [test] Fix imports in tests/config_test.py --- tests/config_test.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/config_test.py b/tests/config_test.py index 6bb1a08..303f4f1 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -1,11 +1,9 @@ -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 import asciinema.config as cfg +from nose.tools import assert_equal, assert_raises def create_config(content=None, env={}): From 533b074759380d3042a6155abc06e9dd9410a4b2 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 12:45:23 -0400 Subject: [PATCH 008/121] [doc] Update README * Add example command for running Docker container as a non-root userx * Update syntax highlighing --- README.md | 112 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f6d151d..9443416 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,27 @@ them in a terminal as well as in a web browser. Install latest version ([other installation options](#installation)): - sudo pip3 install asciinema +```sh +pip3 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 @@ -38,7 +46,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) @@ -47,7 +57,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,6 +67,7 @@ sent anywhere without your consent. These are the basics, but there's much more you can do. The following sections cover installation, usage and hosting of the recordings in more detail. + ## Installation ### Python package @@ -62,7 +75,9 @@ cover installation, usage and hosting of the recordings in more detail. asciinema is available on [PyPI](https://pypi.python.org/pypi/asciinema) and can be installed with pip (Python 3 with setuptools required): - sudo pip3 install asciinema +```sh +pip3 install asciinema +``` This is the recommended way of installation, which gives you the latest released version. @@ -80,32 +95,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 @@ -117,9 +145,26 @@ 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 @@ -196,27 +241,37 @@ Following keyboard shortcuts are available: 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 `` 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: @@ -391,6 +446,7 @@ source [contributors](https://github.com/asciinema/asciinema/contributors). ## License -Copyright © 2011–2019 Marcin Kulik. +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. From 9eab5f5cb7220e65ee39f2cea36e3665ddc61b13 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 12:55:12 -0400 Subject: [PATCH 009/121] [container] Update Dockerfile for PEP 518 build --- Dockerfile | 15 ++++++------ tests/distros/Dockerfile.alpine | 18 +++++++++++++++ tests/distros/Dockerfile.arch | 22 ++++++++++++++++++ tests/distros/Dockerfile.centos | 24 ++++++++++++------- tests/distros/Dockerfile.debian | 41 ++++++++++++++++++++++++--------- tests/distros/Dockerfile.fedora | 25 ++++++++++++++------ tests/distros/Dockerfile.ubuntu | 40 +++++++++++++++++++++++--------- 7 files changed, 141 insertions(+), 44 deletions(-) create mode 100644 tests/distros/Dockerfile.alpine create mode 100644 tests/distros/Dockerfile.arch diff --git a/Dockerfile b/Dockerfile index a52a5c4..43eb960 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,27 +9,28 @@ RUN apt-get update \ ca-certificates \ locales \ python3 \ - python3-setuptools \ + python3-pip \ && localedef \ -i en_US \ -c \ -f UTF-8 \ - -A /usr/share/locale/locale.alias en_US.UTF-8 + -A /usr/share/locale/locale.alias \ + en_US.UTF-8 -COPY setup.cfg setup.py *.md /usr/src/app/ +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 README.md LICENSE /usr/src/app/ WORKDIR /usr/src/app -RUN python3 setup.py install +RUN pip3 install . + +WORKDIR /root ENV LANG="en_US.utf8" ENV SHELL="/bin/bash" -ENV USER="docker" - -WORKDIR /root ENTRYPOINT ["/usr/local/bin/asciinema"] CMD ["--help"] diff --git a/tests/distros/Dockerfile.alpine b/tests/distros/Dockerfile.alpine new file mode 100644 index 0000000..3fec48f --- /dev/null +++ b/tests/distros/Dockerfile.alpine @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1.3 + +FROM docker.io/library/alpine:3.14 + +RUN apk --no-cache add bash ca-certificates python3 + +WORKDIR /usr/src/app + +COPY asciinema/ asciinema/ +COPY tests/ tests/ + +ENV LANG="en_US.utf8" + +USER nobody + +ENTRYPOINT ["/bin/bash"] + +# vim:ft=dockerfile diff --git a/tests/distros/Dockerfile.arch b/tests/distros/Dockerfile.arch new file mode 100644 index 0000000..4b4f6cb --- /dev/null +++ b/tests/distros/Dockerfile.arch @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1.3 + +FROM docker.io/library/archlinux:latest + +RUN pacman-key --init \ + && pacman --sync --refresh --sysupgrade --noconfirm 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 diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos index e80252c..c1693ee 100644 --- a/tests/distros/Dockerfile.centos +++ b/tests/distros/Dockerfile.centos @@ -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 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 diff --git a/tests/distros/Dockerfile.debian b/tests/distros/Dockerfile.debian index cdd57aa..4bb4e4c 100644 --- a/tests/distros/Dockerfile.debian +++ b/tests/distros/Dockerfile.debian @@ -1,13 +1,32 @@ -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 \ + 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 diff --git a/tests/distros/Dockerfile.fedora b/tests/distros/Dockerfile.fedora index f4dcce2..6be7951 100644 --- a/tests/distros/Dockerfile.fedora +++ b/tests/distros/Dockerfile.fedora @@ -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 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 diff --git a/tests/distros/Dockerfile.ubuntu b/tests/distros/Dockerfile.ubuntu index dff66a7..60a6bf6 100644 --- a/tests/distros/Dockerfile.ubuntu +++ b/tests/distros/Dockerfile.ubuntu @@ -1,13 +1,31 @@ -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 \ + 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 From dcf4f3f9ca21938f4c185ae8d348ce1d8e27d6cd Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 13:02:21 -0400 Subject: [PATCH 010/121] [test] Update test distro Dockerfiles * Add Dockerfile.{alpine,arch} * Add test.distros Makefile target --- Makefile | 4 ++++ tests/distros.sh | 32 ++++++++++++++++++++++---------- tests/integration.sh | 2 +- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index ce13182..72ecea7 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,10 @@ test.unit: test.integration: tests/integration.sh +.PHONY: test.distros +test.distros: + tests/distros.sh + .PHONY: release release: test tag push diff --git a/tests/distros.sh b/tests/distros.sh index 48f70bc..c34d272 100755 --- a/tests/distros.sh +++ b/tests/distros.sh @@ -1,26 +1,38 @@ #!/usr/bin/env bash -set -e +set -euo pipefail +readonly DISTROS=( + 'arch' + 'alpine' + 'centos' + 'debian' + 'fedora' + 'ubuntu' +) + +readonly DOCKER='docker' # 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}" - docker build \ - --tag="asciinema/asciinema:${tag}" \ - --file="tests/distros/Dockerfile.${tag}" \ - . + # shellcheck disable=SC2068 + "${DOCKER}" build ${docker_opts[@]} . - docker run --rm -i "asciinema/asciinema:${tag}" tests/integration.sh + "${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh } -test_ ubuntu -test_ debian -test_ fedora -test_ centos +for distro in "${DISTROS[@]}"; do + test_ "${distro}" +done printf "\n\e[1;32mAll tests passed.\e[0m\n" diff --git a/tests/integration.sh b/tests/integration.sh index 1d0cb11..c4554df 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -ex +set -eExuo pipefail if ! command -v "pkill" >/dev/null 2>&1; then printf "error: pkill not installed\n" From bd3e6f5f676a61fe0f0a8e31ae7e9152761f797b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 13:21:45 -0400 Subject: [PATCH 011/121] [test] Convert unittests runner nose => pytest Per the official docs, nose is in maintenance mode. Attempts to import `Callable` from `collections` fails in Python 3.10. Convert nose-specific assert statements to bare asserts supported by pytest. * https://nose.readthedocs.io/en/latest/#note-to-users * https://github.com/pytest-dev/nose2pytest --- Makefile | 2 +- tests/config_test.py | 138 +++++++++++++++++++++++-------------------- tests/pty_test.py | 22 ++++--- 3 files changed, 89 insertions(+), 73 deletions(-) diff --git a/Makefile b/Makefile index 72ecea7..8c985bf 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ test: test.unit test.integration .PHONY: test.unit test.unit: - nosetests + pytest .PHONY: test.integration test.integration: diff --git a/tests/config_test.py b/tests/config_test.py index 303f4f1..7140841 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -3,22 +3,23 @@ import tempfile from os import path import asciinema.config as cfg -from nose.tools import assert_equal, assert_raises def create_config(content=None, env={}): - dir = tempfile.mkdtemp() + # 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: + with open(install_id_path, "rt", encoding="utf_8") as f: return f.read().strip() @@ -27,180 +28,187 @@ def test_upgrade_no_config_file(): 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(): 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") + 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(): 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") + 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) + config = create_config("") + assert config.api_url == "https://asciinema.org" def test_default_record_stdin(): - config = create_config('') - assert_equal(False, config.record_stdin) + config = create_config("") + assert config.record_stdin is False def test_default_record_command(): - config = create_config('') - assert_equal(None, config.record_command) + config = create_config("") + assert config.record_command is None def test_default_record_env(): - config = create_config('') - assert_equal('SHELL,TERM', config.record_env) + 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) + 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) + config = create_config("") + assert config.record_yes is False def test_default_record_quiet(): - config = create_config('') - assert_equal(False, config.record_quiet) + 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) + config = create_config("") + assert config.play_idle_time_limit is None def test_api_url(): config = create_config("[api]\nurl = http://the/url") - assert_equal('http://the/url', config.api_url) + assert config.api_url == "http://the/url" def test_api_url_when_override_set(): - config = create_config("[api]\nurl = http://the/url", { - 'ASCIINEMA_API_URL': 'http://the/url2'}) - assert_equal('http://the/url2', config.api_url) + 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' + command = "bash -l" config = create_config("[record]\ncommand = %s" % command) - assert_equal(command, config.record_command) + assert config.record_command == command def test_record_stdin(): config = create_config("[record]\nstdin = yes") - assert_equal(True, config.record_stdin) + assert config.record_stdin is True def test_record_env(): 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(): 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' + yes = "yes" config = create_config("[record]\nyes = %s" % yes) - assert_equal(True, config.record_yes) + assert config.record_yes is True def test_record_quiet(): - quiet = 'yes' + quiet = "yes" config = create_config("[record]\nquiet = %s" % quiet) - assert_equal(True, config.record_quiet) + assert config.record_quiet is True def test_play_idle_time_limit(): 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) + 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) + 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"' diff --git a/tests/pty_test.py b/tests/pty_test.py index 8f42c14..cca35a3 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -1,14 +1,12 @@ import os import pty -from nose.tools import assert_equal -from .test_helper import Test - import asciinema.pty +from .test_helper import Test + class FakeStdout: - def __init__(self): self.data = [] @@ -20,7 +18,6 @@ class FakeStdout: class TestRecord(Test): - def setUp(self): self.real_os_write = os.write os.write = self.os_write @@ -35,7 +32,18 @@ class TestRecord(Test): def test_record_command_writes_to_stdout(self): output = FakeStdout() - command = ['python3', '-c', "import sys; import time; sys.stdout.write(\'foo\'); sys.stdout.flush(); time.sleep(0.01); sys.stdout.write(\'bar\')"] + 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) - assert_equal([b'foo', b'bar'], output.data) + assert output.data == [b"foo", b"bar"] From 66534d258dc90020a3e77c405bce459524154817 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 14:09:53 -0400 Subject: [PATCH 012/121] [ci] Run container integration tests to CI --- .github/workflows/asciinema.yml | 78 ++++++++++++++++++++++++++++----- tests/distros/Dockerfile.alpine | 3 +- tests/distros/Dockerfile.arch | 2 +- tests/distros/Dockerfile.centos | 2 +- tests/distros/Dockerfile.debian | 1 + tests/distros/Dockerfile.fedora | 2 +- tests/distros/Dockerfile.ubuntu | 1 + 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 251b7ef..49d126e 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -1,22 +1,21 @@ +--- name: build - 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 + - name: checkout asciinema uses: actions/checkout@v2 - - name: Setup Python + - name: setup Python uses: actions/setup-python@v2 with: python-version: "3.9" - - name: Install dependencies + - name: install dependencies run: pip install build cmarkgfm pycodestyle twine - name: Run pycodestyle run: > @@ -27,7 +26,7 @@ jobs: twine check dist/* # Asciinema checks asciinema: - name: Asciinema - py${{ matrix.python }} + name: Asciinema runs-on: ubuntu-latest strategy: matrix: @@ -40,13 +39,70 @@ jobs: env: TERM: dumb steps: - - name: Checkout Asciinema + - name: checkout Asciinema uses: actions/checkout@v2 - - name: Setup Python + - name: setup Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - - name: Install dependencies + - name: install dependencies run: pip install pytest - - name: Run Asciinema tests + - 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@v1 + - name: Authenticate to GHCR + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: "${{ github.actor }}" + password: "${{ secrets.GITHUB_TOKEN }}" + - name: "Build ${{ matrix.distros }} image" + uses: docker/build-push-action@v2 + 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@v2 + - name: run integration tests + env: + TERM: dumb + shell: 'script --return --quiet --command "bash {0}"' + run: make test.integration diff --git a/tests/distros/Dockerfile.alpine b/tests/distros/Dockerfile.alpine index 3fec48f..bdc3fc8 100644 --- a/tests/distros/Dockerfile.alpine +++ b/tests/distros/Dockerfile.alpine @@ -2,7 +2,8 @@ FROM docker.io/library/alpine:3.14 -RUN apk --no-cache add bash ca-certificates python3 +# https://github.com/actions/runner/issues/241 +RUN apk --no-cache add bash ca-certificates make python3 util-linux WORKDIR /usr/src/app diff --git a/tests/distros/Dockerfile.arch b/tests/distros/Dockerfile.arch index 4b4f6cb..3224495 100644 --- a/tests/distros/Dockerfile.arch +++ b/tests/distros/Dockerfile.arch @@ -3,7 +3,7 @@ FROM docker.io/library/archlinux:latest RUN pacman-key --init \ - && pacman --sync --refresh --sysupgrade --noconfirm python3 \ + && pacman --sync --refresh --sysupgrade --noconfirm make python3 \ && printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \ && locale-gen \ && pacman --sync --clean --clean --noconfirm diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos index c1693ee..bc4fd7e 100644 --- a/tests/distros/Dockerfile.centos +++ b/tests/distros/Dockerfile.centos @@ -2,7 +2,7 @@ FROM docker.io/library/centos:7 -RUN yum install -y epel-release && yum install -y python36 && yum clean all +RUN yum install -y epel-release && yum install -y make python36 && yum clean all WORKDIR /usr/src/app diff --git a/tests/distros/Dockerfile.debian b/tests/distros/Dockerfile.debian index 4bb4e4c..6c14287 100644 --- a/tests/distros/Dockerfile.debian +++ b/tests/distros/Dockerfile.debian @@ -8,6 +8,7 @@ RUN apt-get update \ && apt-get install -y \ ca-certificates \ locales \ + make \ procps \ python3 \ && localedef \ diff --git a/tests/distros/Dockerfile.fedora b/tests/distros/Dockerfile.fedora index 6be7951..e5abb51 100644 --- a/tests/distros/Dockerfile.fedora +++ b/tests/distros/Dockerfile.fedora @@ -4,7 +4,7 @@ # https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html FROM registry.fedoraproject.org/fedora:34 -RUN dnf install -y python3 procps && dnf clean all +RUN dnf install -y make python3 procps && dnf clean all WORKDIR /usr/src/app diff --git a/tests/distros/Dockerfile.ubuntu b/tests/distros/Dockerfile.ubuntu index 60a6bf6..38223c2 100644 --- a/tests/distros/Dockerfile.ubuntu +++ b/tests/distros/Dockerfile.ubuntu @@ -8,6 +8,7 @@ RUN apt-get update \ && apt-get install -y \ ca-certificates \ locales \ + make \ python3 \ && localedef \ -i en_US \ From bb906242923d2b1f661206c910b11d67eb6a1ef7 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 15:41:11 -0400 Subject: [PATCH 013/121] [ci] Add dependabot config --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..900df32 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" From adfdef62f6474275c279a39adbbdbc9365e09847 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 16:24:01 -0400 Subject: [PATCH 014/121] [style] Add pre-commit config https://pre-commit.com/ --- .pre-commit-config.yaml | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 29 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1a22094 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/PyCQA/isort + rev: 5.9.3 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 21.9b0 + 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 diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..bfcb02f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,32 @@ [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 = [] From 6a213cdf4ed209d1617e0d93fa74103fae2c5de6 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 16:29:27 -0400 Subject: [PATCH 015/121] [style] Format package code * Sort imports with isort * Format code with black * Remove unused imports with autoflake --- asciinema/__main__.py | 150 ++++++++++++++++++++++++------- asciinema/api.py | 36 ++++---- asciinema/asciicast/__init__.py | 62 +++++++------ asciinema/asciicast/v1.py | 20 +++-- asciinema/asciicast/v2.py | 68 ++++++++------ asciinema/async_worker.py | 7 +- asciinema/commands/cat.py | 5 +- asciinema/commands/play.py | 11 +-- asciinema/commands/record.py | 52 +++++++---- asciinema/commands/upload.py | 9 +- asciinema/config.py | 105 +++++++++++++--------- asciinema/urllib_http_adapter.py | 61 ++++++++----- 12 files changed, 377 insertions(+), 209 deletions(-) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index 73d715e..739f3f6 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -1,14 +1,14 @@ -import locale import argparse +import locale import os import sys -from asciinema import __version__ import asciinema.config as config +from asciinema import __version__ 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.play import PlayCommand +from asciinema.commands.record import RecordCommand from asciinema.commands.upload import UploadCommand @@ -26,14 +26,20 @@ def maybe_str(v): 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.") + 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) try: cfg = config.load() except config.ConfigError as e: - sys.stderr.write(str(e) + '\n') + sys.stderr.write(str(e) + "\n") sys.exit(1) # create the top-level parser @@ -57,52 +63,132 @@ def main(): For help on a specific command run: \x1b[1masciinema -h\x1b[0m""", - formatter_class=argparse.RawDescriptionHelpFormatter + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--version", action="version", version="asciinema %s" % __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') + 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", + ) parser_rec.set_defaults(cmd=RecordCommand) # create the parser for the "play" command - parser_play = subparsers.add_parser('play', help='Replay terminal session') - parser_play.add_argument('-i', '--idle-time-limit', help='limit idle time during playback to given number of seconds', type=positive_float, default=maybe_str(cfg.play_idle_time_limit)) - parser_play.add_argument('-s', '--speed', help='playback speedup (can be fractional)', type=positive_float, default=cfg.play_speed) - parser_play.add_argument('filename', help='local path, http/ipfs URL or "-" (read from stdin)') + parser_play = 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)') + 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') + 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') + 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) @@ -111,5 +197,5 @@ For help on a specific command run: sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/asciinema/api.py b/asciinema/api.py index 2bf8c39..0e31376 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -1,11 +1,11 @@ +import json import platform import re -import json from urllib.parse import urlparse from asciinema import __version__ -from asciinema.urllib_http_adapter import URLLibHttpAdapter from asciinema.http_adapter import HTTPConnectionError +from asciinema.urllib_http_adapter import URLLibHttpAdapter class APIError(Exception): @@ -13,12 +13,13 @@ class APIError(Exception): class Api: - def __init__(self, url, user, install_id, http_adapter=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): return urlparse(self.url).hostname @@ -30,14 +31,14 @@ class Api: return "{}/api/asciicasts".format(self.url) def upload_asciicast(self, path): - with open(path, 'rb') as f: + 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)) @@ -45,24 +46,25 @@ class Api: if status != 200 and status != 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'} + return {"User-Agent": self._user_agent(), "Accept": "application/json"} def _user_agent(self): - os = re.sub('([^-]+)-(.*)', '\\1/\\2', platform.platform()) + os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform()) - return 'asciinema/%s %s/%s %s' % (__version__, - platform.python_implementation(), - platform.python_version(), - os - ) + return "asciinema/%s %s/%s %s" % ( + __version__, + platform.python_implementation(), + platform.python_version(), + os, + ) def _handle_error(self, status, body): errors = { @@ -71,7 +73,7 @@ class Api: 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." + 503: "The server is down for maintenance. Try again in a minute.", } error = errors.get(status) diff --git a/asciinema/asciicast/__init__.py b/asciinema/asciicast/__init__.py index a43fb6b..197a068 100644 --- a/asciinema/asciicast/__init__.py +++ b/asciinema/asciicast/__init__.py @@ -1,14 +1,13 @@ -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 html.parser +import os +import sys +import urllib.error +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): @@ -22,15 +21,18 @@ class Parser(html.parser.HTMLParser): def handle_starttag(self, tag, attrs_list): # look for - if tag == 'link': + if tag == "link": attrs = {} for k, v in attrs_list: attrs[k] = v - if attrs.get('rel') == 'alternate': - type = attrs.get('type') - if type == 'application/asciicast+json' or type == 'application/x-asciicast': - self.url = attrs.get('href') + 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 open_url(url): @@ -44,43 +46,49 @@ def open_url(url): if url.startswith("http:") or url.startswith("https:"): req = Request(url) - req.add_header('Accept-Encoding', 'gzip') + req.add_header("Accept-Encoding", "gzip") response = urlopen(req) body = response url = response.geturl() # final URL after redirects - if response.headers['Content-Encoding'] == 'gzip': + 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() + 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(""" not found in fetched HTML document""") + raise LoadError( + """ not found in fetched HTML document""" + ) if "://" not in new_url: base_url = urlparse(url) if new_url.startswith("/"): - new_url = urlunparse((base_url[0], base_url[1], new_url, '', '', '')) + 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, '', '', '')) + path = os.path.dirname(base_url[2]) + "/" + new_url + new_url = urlunparse( + (base_url[0], base_url[1], path, "", "", "") + ) 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): diff --git a/asciinema/asciicast/v1.py b/asciinema/asciicast/v1.py index 52de050..05ac84c 100644 --- a/asciinema/asciicast/v1.py +++ b/asciinema/asciicast/v1.py @@ -3,7 +3,6 @@ import json.decoder from asciinema.asciicast.events import to_absolute_time - try: JSONDecodeError = json.decoder.JSONDecodeError except AttributeError: @@ -15,7 +14,6 @@ class LoadError(Exception): class Asciicast: - def __init__(self, attrs): self.version = 1 self.__attrs = attrs @@ -23,13 +21,17 @@ class Asciicast: @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} + 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] + for time, data in self.__attrs["stdout"]: + yield [time, "o", data] def events(self): return self.stdout_events() @@ -38,7 +40,7 @@ class Asciicast: return to_absolute_time(self.__stdout_events()) -class open_from_file(): +class open_from_file: FORMAT_ERROR = "only asciicast v1 format can be opened" def __init__(self, first_line, file): @@ -49,11 +51,11 @@ class open_from_file(): 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: + except JSONDecodeError: raise LoadError(self.FORMAT_ERROR) def __exit__(self, exc_type, exc_value, exc_traceback): diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 2c33cbf..bb752be 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -1,7 +1,6 @@ +import codecs import json import json.decoder -import time -import codecs try: JSONDecodeError = json.decoder.JSONDecodeError @@ -14,12 +13,11 @@ class LoadError(Exception): class Asciicast: - def __init__(self, f, header): self.version = 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): for line in self.__file: @@ -27,7 +25,7 @@ class Asciicast: def stdout_events(self): for time, type, data in self.events(): - if type == 'o': + if type == "o": yield [time, type, data] @@ -35,7 +33,7 @@ def build_from_header_and_file(header, f): 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): @@ -45,11 +43,11 @@ class open_from_file(): def __enter__(self): try: v2_header = json.loads(self.first_line) - if v2_header.get('version') == 2: + if v2_header.get("version") == 2: return build_from_header_and_file(v2_header, self.file) else: raise LoadError(self.FORMAT_ERROR) - except JSONDecodeError as e: + except JSONDecodeError: raise LoadError(self.FORMAT_ERROR) def __exit__(self, exc_type, exc_value, exc_traceback): @@ -57,7 +55,7 @@ class open_from_file(): def get_duration(path): - with open(path, mode='rt', encoding='utf-8') as f: + with open(path, mode="rt", encoding="utf-8") as f: first_line = f.readline() with open_from_file(first_line, f) as a: for last_frame in a.stdout_events(): @@ -66,33 +64,43 @@ def get_duration(path): def build_header(width, height, metadata): - header = {'version': 2, 'width': width, 'height': height} + 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 type(header["width"]) == int + assert type(header["height"]) == int - if 'timestamp' in header: - assert type(header['timestamp']) == int or type(header['timestamp']) == float + if "timestamp" in header: + assert ( + type(header["timestamp"]) == int + or type(header["timestamp"]) == float + ) return header -class writer(): - - def __init__(self, path, metadata=None, append=False, buffering=1, width=None, height=None): +class writer: + 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): @@ -108,19 +116,21 @@ class writer(): def write_stdout(self, ts, data): if type(data) == str: - data = data.encode(encoding='utf-8', errors='strict') + 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') + 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): 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') + line = json.dumps( + obj, ensure_ascii=False, indent=None, separators=(", ", ": ") + ) + self.file.write(line + "\n") diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py index 88ad995..df4a4db 100644 --- a/asciinema/async_worker.py +++ b/asciinema/async_worker.py @@ -3,14 +3,13 @@ try: # 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 except ImportError: - from threading import Thread as Process from queue import Queue + from threading import Thread as Process -class async_worker(): - +class async_worker: def __init__(self): self.queue = Queue() diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index fe12c90..bef8fc1 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -1,19 +1,18 @@ import sys +import asciinema.asciicast as asciicast from asciinema.commands.command import Command from asciinema.term import raw -import asciinema.asciicast as asciicast class CatCommand(Command): - def __init__(self, args, config, env): Command.__init__(self, args, config, env) self.filename = args.filename def execute(self): try: - stdin = open('/dev/tty') + 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(): diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py index dd5adf3..38185da 100644 --- a/asciinema/commands/play.py +++ b/asciinema/commands/play.py @@ -1,10 +1,9 @@ +import asciinema.asciicast as asciicast from asciinema.commands.command import Command from asciinema.player import Player -import asciinema.asciicast as asciicast class PlayCommand(Command): - def __init__(self, args, config, env, player=None): Command.__init__(self, args, config, env) self.filename = args.filename @@ -12,14 +11,16 @@ class PlayCommand(Command): 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): 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)) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index b8f9423..3a34b6f 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -2,16 +2,15 @@ import os import sys import tempfile -import asciinema.recorder as recorder import asciinema.asciicast.raw as raw import asciinema.asciicast.v2 as v2 import asciinema.notifier as notifier +import asciinema.recorder as recorder from asciinema.api import APIError from asciinema.commands.command import Command class RecordCommand(Command): - def __init__(self, args, config, env): Command.__init__(self, args, config, env) self.quiet = args.quiet @@ -26,11 +25,13 @@ class RecordCommand(Command): 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): @@ -39,7 +40,9 @@ class RecordCommand(Command): 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() @@ -56,8 +59,12 @@ class RecordCommand(Command): elif os.stat(self.filename).st_size > 0 and not append: self.print_error("%s already exists, aborting" % self.filename) - self.print_error("use --overwrite option if you want to overwrite existing recording") - self.print_error("use --append option if you want to append to existing recording") + 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 if append: @@ -68,9 +75,13 @@ class RecordCommand(Command): if self.command: self.print_info("""exit opened program when you're done""") else: - self.print_info("""press or type "exit" when you're done""") + self.print_info( + """press or type "exit" when you're done""" + ) - vars = filter(None, map((lambda var: var.strip()), self.env_whitelist.split(','))) + vars = filter( + None, map((lambda var: var.strip()), self.env_whitelist.split(",")) + ) try: recorder.record( @@ -84,18 +95,22 @@ class RecordCommand(Command): rec_stdin=self.rec_stdin, writer=self.writer, notifier=self.notifier, - key_bindings=self.key_bindings + key_bindings=self.key_bindings, ) 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 to upload to %s, to save locally" - % self.api.hostname()) + self.print_info( + "press to upload to %s, to save locally" + % self.api.hostname() + ) try: sys.stdin.readline() except KeyboardInterrupt: @@ -110,12 +125,15 @@ 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( + "retry later by running: asciinema upload %s" + % self.filename + ) return 1 else: self.print_info("asciicast saved to %s" % self.filename) @@ -124,6 +142,6 @@ class RecordCommand(Command): def _tmp_path(): - fd, path = tempfile.mkstemp(suffix='-ascii.cast') + fd, path = tempfile.mkstemp(suffix="-ascii.cast") os.close(fd) return path diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py index 897ecb5..80f3f0f 100644 --- a/asciinema/commands/upload.py +++ b/asciinema/commands/upload.py @@ -1,9 +1,8 @@ -from asciinema.commands.command import Command from asciinema.api import APIError +from asciinema.commands.command import Command class UploadCommand(Command): - def __init__(self, args, config, env): Command.__init__(self, args, config, env) self.filename = args.filename @@ -15,7 +14,7 @@ class UploadCommand(Command): 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)) @@ -23,7 +22,9 @@ class UploadCommand(Command): 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( + "retry later by running: asciinema upload %s" % self.filename + ) return 1 return 0 diff --git a/asciinema/config.py b/asciinema/config.py index 75703c0..0ecc071 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -1,24 +1,22 @@ +import configparser import os import os.path as path -import sys import uuid -import configparser class ConfigError(Exception): pass -DEFAULT_API_URL = 'https://asciinema.org' -DEFAULT_RECORD_ENV = 'SHELL,TERM' +DEFAULT_API_URL = "https://asciinema.org" +DEFAULT_RECORD_ENV = "SHELL,TERM" class Config: - def __init__(self, config_home, env=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 @@ -27,20 +25,31 @@ class Config: try: self.install_id except ConfigError: - id = self.__api_token() or self.__user_token() or self.__gen_install_id() + id = ( + self.__api_token() + or self.__user_token() + or self.__gen_install_id() + ) self.__save_install_id(id) - items = {name: dict(section) for (name, section) in self.config.items()} - 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 == {"DEFAULT": {}, "api": {"token": id}} or items == { + "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): p = self.install_id_path if path.isfile(p): - with open(p, 'r') as f: + with open(p, "r") as f: return f.read().strip() def __gen_install_id(self): @@ -49,7 +58,7 @@ class Config: def __save_install_id(self, id): self.__create_config_home() - with open(self.install_id_path, 'w') as f: + with open(self.install_id_path, "w") as f: f.write(id) def __create_config_home(self): @@ -58,103 +67,117 @@ class Config: def __api_token(self): try: - return self.config.get('api', 'token') + return self.config.get("api", "token") except (configparser.NoOptionError, configparser.NoSectionError): pass def __user_token(self): try: - return self.config.get('user', 'token') + return self.config.get("user", "token") except (configparser.NoOptionError, configparser.NoSectionError): pass @property def install_id(self): - id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id() + id = self.env.get("ASCIINEMA_INSTALL_ID") or self.__read_install_id() if id: return id else: - raise ConfigError('no install ID found') + raise ConfigError("no install ID found") @property def api_url(self): 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) + return self.config.getboolean("record", "stdin", fallback=False) @property def record_command(self): - return self.config.get('record', 'command', fallback=None) + return self.config.get("record", "command", fallback=None) @property def record_env(self): - return self.config.get('record', 'env', fallback=DEFAULT_RECORD_ENV) + 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) + 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) + return self.config.getboolean("record", "yes", fallback=False) @property def record_quiet(self): - return self.config.getboolean('record', 'quiet', fallback=False) + return self.config.getboolean("record", "quiet", fallback=False) @property def record_prefix_key(self): - return self.__get_key('record', 'prefix') + return self.__get_key("record", "prefix") @property def record_pause_key(self): - return self.__get_key('record', 'pause', 'C-\\') + 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) + 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) + return self.config.getfloat("play", "speed", fallback=1.0) @property def play_pause_key(self): - return self.__get_key('play', 'pause', ' ') + return self.__get_key("play", "pause", " ") @property def play_step_key(self): - return self.__get_key('play', 'step', '.') + return self.__get_key("play", "step", ".") @property def notifications_enabled(self): - return self.config.getboolean('notifications', 'enabled', fallback=True) + return self.config.getboolean( + "notifications", "enabled", fallback=True + ) @property def notifications_command(self): - return self.config.get('notifications', 'command', fallback=None) + 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) + key = self.config.get(section, name + "_key", fallback=default) if key: if len(key) == 3: upper_key = key.upper() - if upper_key[0] == 'C' and upper_key[1] == '-': + 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)) + 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') + return key.encode("utf-8") def get_config_home(env=os.environ): @@ -175,7 +198,9 @@ 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 diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py index 796a3cf..846967b 100644 --- a/asciinema/urllib_http_adapter.py +++ b/asciinema/urllib_http_adapter.py @@ -1,24 +1,26 @@ +import base64 import codecs +import http +import io import sys import uuid -import io -import base64 -import http - -from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + 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) + self.content_type = "multipart/form-data; boundary={}".format( + self.boundary + ) @classmethod def u(cls, s): 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): @@ -27,28 +29,36 @@ class MultipartFormdataEncoder: files is a dict of {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') + 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): 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("--{}\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") data = f.read() yield (data, len(data)) - yield encoder('\r\n') - yield encoder('--{}--\r\n'.format(self.boundary)) + yield encoder("\r\n") + yield encoder("--{}--\r\n".format(self.boundary)) def encode(self, fields, files): body = io.BytesIO() @@ -58,8 +68,15 @@ class MultipartFormdataEncoder: class URLLibHttpAdapter: - - def post(self, url, fields={}, files={}, headers={}, username=None, password=None): + def post( + self, + url, + fields={}, + files={}, + headers={}, + username=None, + password=None, + ): content_type, body = MultipartFormdataEncoder().encode(fields, files) headers = headers.copy() @@ -76,11 +93,11 @@ class URLLibHttpAdapter: response = urlopen(request) status = response.status headers = self._parse_headers(response) - body = response.read().decode('utf-8') + body = response.read().decode("utf-8") except HTTPError as e: status = e.code headers = {} - body = e.read().decode('utf-8') + body = e.read().decode("utf-8") except (http.client.RemoteDisconnected, URLError) as e: raise HTTPConnectionError(str(e)) From 08f624b4a45a0b71a263d1477b03e3cbab37e109 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 30 Oct 2021 16:34:53 -0400 Subject: [PATCH 016/121] [style] Ignore line break before binary operator * https://www.python.org/dev/peps/pep-0008/#should-a-line-break-before-or-after-a-binary-operator * https://www.flake8rules.com/rules/W503.html --- .github/workflows/asciinema.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 49d126e..2bfae0a 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -19,7 +19,9 @@ jobs: run: pip install build cmarkgfm pycodestyle twine - name: Run pycodestyle run: > - find . -name '*\.py' -exec pycodestyle --ignore=E501,E402,E722 "{}" \+ + find . + -name '*\.py' + -exec pycodestyle --ignore=E402,E501,E722,W503 "{}" \+ - name: Run twine run: | python3 -m build @@ -97,7 +99,7 @@ jobs: 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" + options: "--interactive --tty --user=1001:121" steps: - name: checkout Asciinema uses: actions/checkout@v2 From 955e2f185a6a8d424b97ceeaa33fe3801ca37bcf Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 31 Oct 2021 12:17:56 -0400 Subject: [PATCH 017/121] [style] Replace calls to format() with f-strings --- asciinema/__main__.py | 2 +- asciinema/api.py | 16 +++++------ asciinema/asciicast/__init__.py | 4 +-- asciinema/commands/auth.py | 17 +++++------ asciinema/commands/cat.py | 2 +- asciinema/commands/command.py | 9 +++--- asciinema/commands/play.py | 2 +- asciinema/commands/record.py | 21 +++++++------- asciinema/commands/upload.py | 6 ++-- asciinema/config.py | 6 ++-- asciinema/notifier.py | 49 ++++++++++++++++++++++++-------- asciinema/urllib_http_adapter.py | 20 ++++++------- 12 files changed, 87 insertions(+), 67 deletions(-) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index 739f3f6..c48cfaa 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -66,7 +66,7 @@ For help on a specific command run: formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( - "--version", action="version", version="asciinema %s" % __version__ + "--version", action="version", version=f"asciinema {__version__}" ) subparsers = parser.add_subparsers() diff --git a/asciinema/api.py b/asciinema/api.py index 0e31376..fc2849b 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -25,10 +25,10 @@ class Api: return urlparse(self.url).hostname def auth_url(self): - return "{}/connect/{}".format(self.url, self.install_id) + return f"{self.url}/connect/{self.install_id}" def upload_url(self): - return "{}/api/asciicasts".format(self.url) + return f"{self.url}/api/asciicasts" def upload_asciicast(self, path): with open(path, "rb") as f: @@ -59,20 +59,18 @@ class Api: def _user_agent(self): 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): errors = { - 400: "Invalid request: %s" % body, + 400: f"Invalid request: {body}", 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.", 413: "Sorry, your asciicast is too big.", - 422: "Invalid asciicast: %s" % body, + 422: f"Invalid asciicast: {body}", 503: "The server is down for maintenance. Try again in a minute.", } diff --git a/asciinema/asciicast/__init__.py b/asciinema/asciicast/__init__.py index 197a068..0bd4a35 100644 --- a/asciinema/asciicast/__init__.py +++ b/asciinema/asciicast/__init__.py @@ -40,9 +40,9 @@ def open_url(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) diff --git a/asciinema/commands/auth.py b/asciinema/commands/auth.py index cc435ce..f5636a4 100644 --- a/asciinema/commands/auth.py +++ b/asciinema/commands/auth.py @@ -2,15 +2,16 @@ from asciinema.commands.command import Command class AuthCommand(Command): - def __init__(self, args, config, env): Command.__init__(self, args, config, env) def execute(self): - self.print('Open the following URL in a web browser to link your ' - '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())) + 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()}." + ) diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index bef8fc1..2348d51 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -20,7 +20,7 @@ class CatCommand(Command): 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 diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index 3ba5ad0..48c9593 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -4,7 +4,6 @@ from asciinema.api import Api class Command: - def __init__(self, args, config, env): self.quiet = False self.api = Api(config.api_url, env.get("USER"), config.install_id) @@ -14,10 +13,12 @@ class Command: print(text, file=file, end=end) def print_info(self, text): - self.print("\x1b[0;32masciinema: %s\x1b[0m" % text) + self.print(f"asciinema: {text}") def print_warning(self, text): - self.print("\x1b[0;33masciinema: %s\x1b[0m" % text) + self.print(f"asciinema: {text}") def print_error(self, text): - self.print("\x1b[0;31masciinema: %s\x1b[0m" % text, file=sys.stderr, force=True) + self.print( + f"asciinema: {text}", file=sys.stderr, force=True + ) diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py index 38185da..68f605a 100644 --- a/asciinema/commands/play.py +++ b/asciinema/commands/play.py @@ -23,7 +23,7 @@ class PlayCommand(Command): ) 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 diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 3a34b6f..d91c290 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -50,7 +50,7 @@ class RecordCommand(Command): if 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: @@ -58,7 +58,7 @@ 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(f"{self.filename} already exists, aborting") self.print_error( "use --overwrite option if you want to overwrite existing recording" ) @@ -68,9 +68,9 @@ class RecordCommand(Command): return 1 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""") @@ -108,14 +108,14 @@ class RecordCommand(Command): if upload: if not self.assume_yes: self.print_info( - "press to upload to %s, to save locally" - % self.api.hostname() + f"press to upload to {self.api.hostname()}" + ", 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: @@ -129,14 +129,13 @@ class RecordCommand(Command): except APIError as e: self.print("\r\x1b[A", end="") - self.print_error("upload failed: %s" % str(e)) + self.print_error(f"upload failed: {str(e)}") self.print_error( - "retry later by running: asciinema upload %s" - % self.filename + 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 diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py index 80f3f0f..f04e80c 100644 --- a/asciinema/commands/upload.py +++ b/asciinema/commands/upload.py @@ -17,13 +17,13 @@ class UploadCommand(Command): 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(f"upload failed: {str(e)}") self.print_error( - "retry later by running: asciinema upload %s" % self.filename + f"retry later by running: asciinema upload {self.filename}" ) return 1 diff --git a/asciinema/config.py b/asciinema/config.py index 0ecc071..aa606d7 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -172,9 +172,9 @@ class Config: 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 - ) + f"invalid {name} key definition '{key}' - use" + f": {name}_key = C-x (with control key modifier)" + f", or {name}_key = x (with no modifier)" ) else: return key.encode("utf-8") diff --git a/asciinema/notifier.py b/asciinema/notifier.py index 1240511..833ac84 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -3,7 +3,7 @@ import shutil import subprocess -class Notifier(): +class Notifier: def is_available(self): return shutil.which(self.cmd) is not None @@ -13,7 +13,10 @@ class Notifier(): # 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") + path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "data/icon-256x256.png", + ) if os.path.exists(path): return path @@ -24,7 +27,11 @@ class AppleScriptNotifier(Notifier): def args(self, text): text = text.replace('"', '\\"') - return ['osascript', '-e', 'display notification "{}" with title "asciinema"'.format(text)] + return [ + "osascript", + "-e", + f'display notification "{text}" with title "asciinema"', + ] class LibNotifyNotifier(Notifier): @@ -34,9 +41,9 @@ class LibNotifyNotifier(Notifier): icon_path = self.get_icon_path() if icon_path is not None: - return ['notify-send', '-i', icon_path, 'asciinema', text] + return ["notify-send", "-i", icon_path, "asciinema", text] else: - return ['notify-send', 'asciinema', text] + return ["notify-send", "asciinema", text] class TerminalNotifier(Notifier): @@ -46,9 +53,23 @@ class TerminalNotifier(Notifier): icon_path = self.get_icon_path() if icon_path is not None: - return ['terminal-notifier', '-title', 'asciinema', '-message', text, '-appIcon', icon_path] + 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, + ] class CustomCommandNotifier(Notifier): @@ -57,14 +78,14 @@ class CustomCommandNotifier(Notifier): self.command = command def notify(self, text): - args = ['/bin/sh', '-c', self.command] + args = ["/bin/sh", "-c", self.command] env = os.environ.copy() - env['TEXT'] = text - env['ICON_PATH'] = self.get_icon_path() + env["TEXT"] = text + env["ICON_PATH"] = self.get_icon_path() subprocess.run(args, env=env, capture_output=True) -class NoopNotifier(): +class NoopNotifier: def notify(self, text): pass @@ -74,7 +95,11 @@ def get_notifier(enabled=True, command=None): if command: return CustomCommandNotifier(command) else: - for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]: + for c in [ + TerminalNotifier, + AppleScriptNotifier, + LibNotifyNotifier, + ]: n = c() if n.is_available(): diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py index 846967b..86a5257 100644 --- a/asciinema/urllib_http_adapter.py +++ b/asciinema/urllib_http_adapter.py @@ -13,9 +13,7 @@ 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 - ) + self.content_type = f"multipart/form-data; boundary={self.boundary}" @classmethod def u(cls, s): @@ -32,11 +30,9 @@ class MultipartFormdataEncoder: encoder = codecs.getencoder("utf-8") for (key, value) in fields.items(): key = self.u(key) - yield encoder("--{}\r\n".format(self.boundary)) + yield encoder(f"--{self.boundary}\r\n") yield encoder( - self.u('Content-Disposition: form-data; name="{}"\r\n').format( - key - ) + self.u(f'Content-Disposition: form-data; name="{key}"\r\n') ) yield encoder("\r\n") if isinstance(value, int) or isinstance(value, float): @@ -47,18 +43,18 @@ class MultipartFormdataEncoder: filename, f = filename_and_f key = self.u(key) filename = self.u(filename) - yield encoder("--{}\r\n".format(self.boundary)) + yield encoder(f"--{self.boundary}\r\n") yield encoder( self.u( - 'Content-Disposition: form-data; name="{}"; filename="{}"\r\n' - ).format(key, filename) + f'Content-Disposition: form-data; name="{key}"; 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(f"--{self.boundary}--\r\n") def encode(self, fields, files): body = io.BytesIO() @@ -83,7 +79,7 @@ class URLLibHttpAdapter: headers["Content-Type"] = content_type if password: - auth = "%s:%s" % (username, password) + auth = f"{username}:{password}" encoded_auth = base64.b64encode(bytes(auth, "utf-8")) headers["Authorization"] = b"Basic " + encoded_auth From a0cd7df404a97421561b4341c1ae1e5872ebbd6d Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 31 Oct 2021 12:18:55 -0400 Subject: [PATCH 018/121] [style] Disable pylint invalid-name --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfcb02f..75eb62c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,4 +29,6 @@ warn_unused_ignores = true exclude = [] [tool.pylint."MESSAGES CONTROL"] -disable = [] +disable = [ + "invalid-name", +] From 12e3bf514a11467ca4d3ffd6d1e3a18d03f4cec5 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 31 Oct 2021 16:30:01 -0400 Subject: [PATCH 019/121] Rename asciinema/pty.py => pty_.py Avoid mypy error: Source file found twice under different module names: "asciinema.pty" and "pty" --- asciinema/{pty.py => pty_.py} | 70 +++++++++++++++++++++-------------- asciinema/recorder.py | 63 ++++++++++++++++++------------- 2 files changed, 80 insertions(+), 53 deletions(-) rename asciinema/{pty.py => pty_.py} (73%) diff --git a/asciinema/pty.py b/asciinema/pty_.py similarity index 73% rename from asciinema/pty.py rename to asciinema/pty_.py index e5d66a6..dcd4a90 100644 --- a/asciinema/pty.py +++ b/asciinema/pty_.py @@ -1,54 +1,59 @@ 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 +from .term import raw -def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None, key_bindings={}): +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') + 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]) + 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]) + 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.''' + """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.''' + """Handles new data on child process stdout.""" if not pause_time: writer.write_stdout(time.time() - start_time, data) @@ -56,14 +61,14 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti _write_stdout(data) def _write_master(data): - '''Writes to the child process from its controlling terminal.''' + """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.''' + """Handles new data on child process stdin.""" nonlocal pause_time nonlocal start_time @@ -80,10 +85,10 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti if pause_time: start_time = start_time + (time.time() - pause_time) pause_time = None - _notify('Resumed recording') + _notify("Resumed recording") else: pause_time = time.time() - _notify('Paused recording') + _notify("Paused recording") return @@ -99,11 +104,11 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti return old_handlers def _copy(signal_fd): - '''Main select loop. + """Main select loop. Passes control to _master_read() or _stdin_read() when new data arrives. - ''' + """ fds = [master_fd, pty.STDIN_FILENO, signal_fd] @@ -134,9 +139,14 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti if signal_fd in rfds: data = os.read(signal_fd, 1024) if data: - signals = struct.unpack('%uB' % len(data), data) + signals = struct.unpack("%uB" % len(data), data) for sig in signals: - if sig in [signal.SIGCHLD, signal.SIGHUP, signal.SIGTERM, signal.SIGQUIT]: + if sig in [ + signal.SIGCHLD, + signal.SIGHUP, + signal.SIGTERM, + signal.SIGQUIT, + ]: os.close(master_fd) return elif sig == signal.SIGWINCH: @@ -154,12 +164,18 @@ def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, noti 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])) + old_handlers = _signals( + map( + lambda s: (s, lambda signal, frame: None), + [ + signal.SIGWINCH, + signal.SIGCHLD, + signal.SIGHUP, + signal.SIGTERM, + signal.SIGQUIT, + ], + ) + ) _set_pty_size() diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 1c02d49..32894cf 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -1,45 +1,54 @@ import os import time -import asciinema.asciicast.v2 as v2 -import asciinema.pty as pty -import asciinema.term as term -from asciinema.async_worker import async_worker +from . import pty_ as pty # avoid +from . import term +from .asciicast import v2 +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( + path, + command=None, + append=False, + idle_time_limit=None, + rec_stdin=False, + title=None, + metadata=None, + command_env=None, + capture_env=None, + writer=v2.writer, + record=pty.record, + notifier=None, + key_bindings={}, +): if command is None: - command = os.environ.get('SHELL') or 'sh' + command = os.environ.get("SHELL") or "sh" if command_env is None: command_env = os.environ.copy() - command_env['ASCIINEMA_REC'] = '1' + command_env["ASCIINEMA_REC"] = "1" if capture_env is None: - capture_env = ['SHELL', 'TERM'] + capture_env = ["SHELL", "TERM"] w, h = term.get_size() - full_metadata = { - 'width': w, - 'height': h, - 'timestamp': int(time.time()) - } + full_metadata = {"width": w, "height": h, "timestamp": int(time.time())} full_metadata.update(metadata or {}) if idle_time_limit is not None: - full_metadata['idle_time_limit'] = idle_time_limit + full_metadata["idle_time_limit"] = idle_time_limit if capture_env: - full_metadata['env'] = {var: command_env.get(var) for var in capture_env} + full_metadata["env"] = { + var: command_env.get(var) for var in capture_env + } if title: - full_metadata['title'] = title + full_metadata["title"] = title time_offset = 0 @@ -49,13 +58,13 @@ def record(path, command=None, append=False, idle_time_limit=None, with async_writer(writer, path, full_metadata, append) as w: with async_notifier(notifier) as n: record( - ['sh', '-c', command], + ["sh", "-c", command], w, command_env, rec_stdin, time_offset, n, - key_bindings + key_bindings, ) @@ -68,19 +77,21 @@ class async_writer(async_worker): self.append = append def write_stdin(self, ts, data): - self.enqueue([ts, 'i', data]) + self.enqueue([ts, "i", data]) def write_stdout(self, ts, data): - self.enqueue([ts, 'o', data]) + self.enqueue([ts, "o", data]) def run(self): - with self.writer(self.path, metadata=self.metadata, append=self.append) as w: + with self.writer( + self.path, metadata=self.metadata, append=self.append + ) as w: for event in iter(self.queue.get, None): ts, etype, data = event - if etype == 'o': + if etype == "o": w.write_stdout(ts, data) - elif etype == 'i': + elif etype == "i": w.write_stdin(ts, data) From d7a07db4f5f21d5b79304fccaffd37c802206dad Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 31 Oct 2021 17:49:39 -0400 Subject: [PATCH 020/121] [typing] Annotate asciinema.asciicast --- asciinema/__init__.py | 33 ++++++--- asciinema/asciicast/__init__.py | 122 ++++++++++++++++++-------------- asciinema/asciicast/events.py | 37 ++++++---- asciinema/asciicast/raw.py | 40 ++++++++--- asciinema/asciicast/v1.py | 40 ++++++----- asciinema/asciicast/v2.py | 114 ++++++++++++++++------------- 6 files changed, 234 insertions(+), 152 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 3d006a5..d0720a8 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -1,19 +1,30 @@ import sys -__author__ = 'Marcin Kulik' -__version__ = '2.0.2' +__author__ = "Marcin Kulik" +__version__ = "2.0.2" -if sys.version_info[0] < 3: - raise ImportError('Python < 3 is unsupported.') +if sys.version_info < (3, 7): + raise ImportError("Python < 3.7 is unsupported.") -import asciinema.recorder +# pylint: disable=wrong-import-position +from typing import Any + +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( + path_, + command=None, + append=False, + idle_time_limit=None, + rec_stdin=False, + title=None, + metadata: Any = None, + command_env: Any = None, + capture_env: Any = None, +) -> None: + record( + path_, command=command, append=append, idle_time_limit=idle_time_limit, @@ -21,5 +32,5 @@ def record_asciicast(path, command=None, append=False, idle_time_limit=None, title=title, metadata=metadata, command_env=command_env, - capture_env=capture_env + capture_env=capture_env, ) diff --git a/asciinema/asciicast/__init__.py b/asciinema/asciicast/__init__.py index 0bd4a35..1cd4a92 100644 --- a/asciinema/asciicast/__init__.py +++ b/asciinema/asciicast/__init__.py @@ -1,9 +1,11 @@ import codecs import gzip -import html.parser 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 @@ -14,28 +16,37 @@ 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 + def error(self, message: str) -> None: + raise NotImplementedError( + "subclasses of ParserBase must override error()" + ", but HTMLParser does not" + ) + + def handle_starttag(self, tag: str, attrs: List[Any]) -> None: + # look for if tag == "link": - attrs = {} - for k, v in attrs_list: - attrs[k] = v + # 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 == "application/asciicast+json" - or type == "application/x-asciicast" + if _attrs.get("rel") == "alternate": + type_ = _attrs.get("type") + if type_ in ( + "application/asciicast+json", + "application/x-asciicast", ): - self.url = attrs.get("href") + self.url = _attrs.get("href") -def open_url(url): +def open_url(url: str) -> Union[StreamReader, TextIO]: if url == "-": return sys.stdin @@ -47,43 +58,46 @@ def open_url(url): 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 + 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( - """ not found in fetched HTML document""" - ) - - 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 not new_url: + raise LoadError( + ' ' + "not found in fetched HTML document" ) - return open_url(new_url) + if "://" not in new_url: + base_url = urlparse(url) - return utf8_reader(body, errors="strict") + 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 utf8_reader(body, errors="strict") return open(url, mode="rt", encoding="utf-8") @@ -91,10 +105,12 @@ def open_url(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() @@ -106,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) diff --git a/asciinema/asciicast/events.py b/asciinema/asciicast/events.py index 730009a..87f81c3 100644 --- a/asciinema/asciicast/events.py +++ b/asciinema/asciicast/events.py @@ -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) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index 1d5dc86..18e5016 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -1,25 +1,43 @@ -import os +from __future__ import annotations + +from os import path, stat +from typing import IO, Any, Optional -class writer(): - - 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 +class writer: + def __init__( + self, + path_: str, + metadata: Any = None, + append: bool = False, + buffering: int = 0, + ) -> None: + if ( + append and path.exists(path_) and stat(path_).st_size == 0 + ): # true for pipes append = False - self.path = path + self.path = path_ self.buffering = buffering - self.mode = 'ab' if append else 'wb' + self.mode: str = "ab" if append else "wb" + self.file: Optional[IO[Any]] = None + self.metadata = metadata - def __enter__(self): + def __enter__(self) -> writer: self.file = open(self.path, mode=self.mode, buffering=self.buffering) return self - def __exit__(self, exc_type, exc_value, exc_traceback): + def __exit__( + self, exc_type: str, exc_value: str, exc_traceback: str + ) -> None: + assert self.file is not None self.file.close() - def write_stdout(self, ts, data): + def write_stdout(self, ts: float, data: Any) -> None: + _ = ts + assert self.file is not None 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 diff --git a/asciinema/asciicast/v1.py b/asciinema/asciicast/v1.py index 05ac84c..6be9103 100644 --- a/asciinema/asciicast/v1.py +++ b/asciinema/asciicast/v1.py @@ -1,12 +1,13 @@ import json -import json.decoder +from codecs import StreamReader +from typing import Any, Dict, Generator, List, Optional, TextIO, Union -from asciinema.asciicast.events import to_absolute_time +from .events import to_absolute_time try: - JSONDecodeError = json.decoder.JSONDecodeError -except AttributeError: - JSONDecodeError = ValueError + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError # type: ignore class LoadError(Exception): @@ -14,13 +15,13 @@ 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): + def v2_header(self) -> Dict[str, Any]: keys = ["width", "height", "duration", "command", "title", "env"] header = { k: v @@ -29,34 +30,37 @@ class Asciicast: } return header - def __stdout_events(self): + 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" + 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: return Asciicast(attrs) - else: - raise LoadError(self.FORMAT_ERROR) - except JSONDecodeError: 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() diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index bb752be..10437a5 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -1,11 +1,11 @@ +from __future__ import annotations + import codecs import json -import json.decoder - -try: - JSONDecodeError = json.decoder.JSONDecodeError -except AttributeError: - JSONDecodeError = ValueError +from codecs import StreamReader +from io import IOBase +from json.decoder import JSONDecodeError +from typing import IO, Any, Dict, Generator, List, Optional, TextIO, Union class LoadError(Exception): @@ -13,88 +13,97 @@ 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") - 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: 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) -> Optional[Asciicast]: try: - v2_header = json.loads(self.first_line) + 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: 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: + assert isinstance(a, Asciicast) + last_frame = None for last_frame in a.stdout_events(): pass return last_frame[0] -def build_header(width, height, metadata): +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 isinstance(header["width"], int) + assert isinstance(header["height"], int) if "timestamp" in header: - assert ( - type(header["timestamp"]) == int - or type(header["timestamp"]) == float - ) + assert isinstance(header["timestamp"], (int, float)) return header class writer: - def __init__( + def __init__( # pylint: disable=too-many-arguments self, - path, - metadata=None, - append=False, - buffering=1, - width=None, - height=None, - ): + path: str, + metadata: Any = None, + append: bool = False, + buffering: int = 1, + width: Optional[int] = None, + height: Optional[int] = None, + ) -> None: self.path = path self.buffering = buffering self.stdin_decoder = codecs.getincrementaldecoder("UTF-8")("replace") self.stdout_decoder = codecs.getincrementaldecoder("UTF-8")("replace") + self.file: Optional[IO[Any]] = None if append: self.mode = "a" @@ -103,34 +112,43 @@ class writer: 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) -> writer: + self.file = open( + self.path, + mode=self.mode, + buffering=self.buffering, + encoding="utf-8", + ) if self.header: self.__write_line(self.header) return self - def __exit__(self, exc_type, exc_value, exc_traceback): + def __exit__( + self, exc_type: str, exc_value: str, exc_traceback: str + ) -> None: + assert isinstance(self.file, IOBase) self.file.close() - def write_stdout(self, ts, data): - if type(data) == str: + 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) - def write_stdin(self, ts, data): - if type(data) == str: + 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) - def __write_event(self, ts, etype, data): + def __write_event(self, ts: float, etype: str, data: str) -> None: self.__write_line([round(ts, 6), etype, data]) - def __write_line(self, obj): + def __write_line(self, obj: Any) -> None: line = json.dumps( obj, ensure_ascii=False, indent=None, separators=(", ", ": ") ) - self.file.write(line + "\n") + assert isinstance(self.file, IOBase) + self.file.write(f"{line}\n") From faa7675c8e4f5771937c686f762b54f27bb9ca17 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 31 Oct 2021 23:38:38 -0400 Subject: [PATCH 021/121] [typing] Annotate asciinema.async_worker --- asciinema/async_worker.py | 46 ++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py index df4a4db..e9c72b7 100644 --- a/asciinema/async_worker.py +++ b/asciinema/async_worker.py @@ -1,30 +1,46 @@ -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 __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: from multiprocessing import Process, Queue -except ImportError: - from queue import Queue - from threading import Thread as Process +else: + 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 Process, Queue, synchronize + + # pylint: disable=pointless-statement + lambda _=synchronize: None # avoid pruning import + except ImportError: + from queue import Queue + from threading import Thread as Process class async_worker: - def __init__(self): - self.queue = Queue() + def __init__(self) -> None: + self.queue: Queue[Any] = Queue() + self.process: Optional[Process] = None - def __enter__(self): + def __enter__(self) -> async_worker: 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): + 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] From 3eae623092d965dc1e4366015e1aa6d5c10a9ee5 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 00:22:09 -0400 Subject: [PATCH 022/121] [test] Update test for asciinema.pty_ --- tests/pty_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/pty_test.py b/tests/pty_test.py index cca35a3..df7aa62 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -1,7 +1,7 @@ import os import pty -import asciinema.pty +import asciinema.pty_ from .test_helper import Test @@ -44,6 +44,6 @@ class TestRecord(Test): "; sys.stdout.write('bar')" ), ] - asciinema.pty.record(command, output) + asciinema.pty_.record(command, output) assert output.data == [b"foo", b"bar"] From ab1fbb0c965b4b6d0646fa05026bba08f86e9638 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 00:57:12 -0400 Subject: [PATCH 023/121] [test] Drop support for Python 3.6; upgrade CentOS PEP 563: _Postponed Evaluation of Annotations_ was introduced as __future__.annotations in Python 3.7. Since future imports are required to be unconditional, this import fails on Python 3.6. This import supports more complete type hinting, esepecially for class methods. Since Ubuntu 18.04 and CentOS 7 are the last major distros to still ship Python 3.6, drop support. --- .github/workflows/asciinema.yml | 1 - tests/distros/Dockerfile.centos | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 2bfae0a..3933e98 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -33,7 +33,6 @@ jobs: strategy: matrix: python: - - "3.6" - "3.7" - "3.8" - "3.9" diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos index bc4fd7e..1a7b629 100644 --- a/tests/distros/Dockerfile.centos +++ b/tests/distros/Dockerfile.centos @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.3 -FROM docker.io/library/centos:7 +FROM docker.io/library/centos:8 -RUN yum install -y epel-release && yum install -y make python36 && yum clean all +RUN yum install -y epel-release && yum install -y make python39 && yum clean all WORKDIR /usr/src/app From 851482120c38dbe829256b36e34afe100afcb853 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 01:32:40 -0400 Subject: [PATCH 024/121] [build] Add clean, clean.all targets to Makefile --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 8c985bf..ac50010 100644 --- a/Makefile +++ b/Makefile @@ -50,3 +50,10 @@ push: .pip build push.test: .pip build python3 -m twine upload --repository testpypi dist/* + +.PHONY: clean +clean: + rm -rf dist *.egg-info + +clean.all: clean + find . -type d -name __pycache__ -o -name .pytest_cache -exec rm -r "{}" + From af590e2e7b212a377a8233a9679529349241323f Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 02:24:11 -0400 Subject: [PATCH 025/121] [typing] Annotate ascinnema.urllib_http_adapter --- asciinema/urllib_http_adapter.py | 86 +++++++++++++++++++------------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py index 86a5257..d09adb9 100644 --- a/asciinema/urllib_http_adapter.py +++ b/asciinema/urllib_http_adapter.py @@ -3,39 +3,44 @@ import codecs import http import io import sys -import uuid +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 + 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") 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") for (key, value) in fields.items(): key = self.u(key) yield encoder(f"--{self.boundary}\r\n") yield encoder( - self.u(f'Content-Disposition: form-data; name="{key}"\r\n') + self.u(f'content-disposition: form-data; name="{key}"\r\n') ) yield encoder("\r\n") - if isinstance(value, int) or isinstance(value, float): + if isinstance(value, (int, float)): value = str(value) yield encoder(self.u(value)) yield encoder("\r\n") @@ -46,62 +51,73 @@ class MultipartFormdataEncoder: yield encoder(f"--{self.boundary}\r\n") yield encoder( self.u( - f'Content-Disposition: form-data; name="{key}"; filename="{filename}"\r\n' + "content-disposition: form-data" + f'; name="{key}"' + f'; filename="{filename}"\r\n' ) ) - yield encoder("Content-Type: application/octet-stream\r\n") + yield encoder("content-type: application/octet-stream\r\n") yield encoder("\r\n") data = f.read() yield (data, len(data)) 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: - def post( +class URLLibHttpAdapter: # pylint: disable=too-few-public-methods + def post( # pylint: disable=too-many-arguments,too-many-locals self, - url, - fields={}, - files={}, - headers={}, - username=None, - password=None, - ): + 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 = {} + content_type, body = MultipartFormdataEncoder().encode(fields, files) headers = headers.copy() - headers["Content-Type"] = content_type + headers["content-type"] = content_type if password: auth = f"{username}:{password}" encoded_auth = base64.b64encode(bytes(auth, "utf-8")) - headers["Authorization"] = b"Basic " + encoded_auth + headers["authorization"] = f"Basic {encoded_auth!r}" 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 From 179c9edc99abd0a17043819cead891ce94965a3b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 02:28:01 -0400 Subject: [PATCH 026/121] [typing] Annotate asciinema.api --- asciinema/api.py | 53 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/asciinema/api.py b/asciinema/api.py index fc2849b..a0eb383 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -1,11 +1,14 @@ +from __future__ import annotations + import json import platform import re +from typing import Any, Callable, Dict, Optional, Tuple, Union from urllib.parse import urlparse -from asciinema import __version__ -from asciinema.http_adapter import HTTPConnectionError -from asciinema.urllib_http_adapter import URLLibHttpAdapter +from . import __version__ +from .http_adapter import HTTPConnectionError +from .urllib_http_adapter import URLLibHttpAdapter class APIError(Exception): @@ -13,7 +16,13 @@ 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 @@ -21,16 +30,16 @@ class Api: http_adapter if http_adapter is not None else URLLibHttpAdapter() ) - def hostname(self): + def hostname(self: Api) -> Optional[str]: return urlparse(self.url).hostname - def auth_url(self): + def auth_url(self: Api) -> str: return f"{self.url}/connect/{self.install_id}" - def upload_url(self): + def upload_url(self: Api) -> str: return f"{self.url}/api/asciicasts" - def upload_asciicast(self, path): + def upload_asciicast(self: Api, path: str) -> Tuple[Any, Any]: with open(path, "rb") as f: try: status, headers, body = self.http_adapter.post( @@ -41,9 +50,9 @@ class Api: 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 in (200, 201): self._handle_error(status, body) if (headers.get("content-type") or "")[0:16] == "application/json": @@ -53,10 +62,12 @@ class Api: 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): + @property + @staticmethod + def _user_agent() -> str: os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform()) return ( @@ -64,11 +75,16 @@ class Api: f"/{platform.python_version()} {os}" ) - def _handle_error(self, status, body): + @staticmethod + def _handle_error(status: int, body: str) -> None: errors = { 400: f"Invalid request: {body}", 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: f"Invalid asciicast: {body}", 503: "The server is down for maintenance. Try again in a minute.", @@ -78,8 +94,11 @@ class Api: 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) From 201ec17aa44aa58f196d2255d4d3516722d78708 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 02:43:23 -0400 Subject: [PATCH 027/121] [typing] Annotate asciinema.config --- asciinema/config.py | 129 +++++++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 60 deletions(-) diff --git a/asciinema/config.py b/asciinema/config.py index aa606d7..02db07d 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -1,19 +1,23 @@ import configparser import os -import os.path as path -import uuid +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") @@ -21,92 +25,94 @@ class Config: 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 = ( + id_ = ( self.__api_token() or self.__user_token() or self.__gen_install_id() ) - self.__save_install_id(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}, - }: + 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" + "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") 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") 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), ) @property - def record_stdin(self): + def record_stdin(self) -> bool: return self.config.getboolean("record", "stdin", fallback=False) @property - def record_command(self): + def record_command(self) -> Optional[str]: return self.config.get("record", "command", fallback=None) @property - def record_env(self): + def record_env(self) -> str: return self.config.get("record", "env", fallback=DEFAULT_RECORD_ENV) @property - def record_idle_time_limit(self): + def record_idle_time_limit(self) -> Optional[float]: fallback = self.config.getfloat( "record", "maxwait", fallback=None ) # pre 2.0 @@ -115,23 +121,23 @@ class Config: ) @property - def record_yes(self): + def record_yes(self) -> bool: return self.config.getboolean("record", "yes", fallback=False) @property - def record_quiet(self): + def record_quiet(self) -> bool: return self.config.getboolean("record", "quiet", fallback=False) @property - def record_prefix_key(self): + def record_prefix_key(self) -> Any: return self.__get_key("record", "prefix") @property - def record_pause_key(self): + def record_pause_key(self) -> Any: return self.__get_key("record", "pause", "C-\\") @property - def play_idle_time_limit(self): + def play_idle_time_limit(self) -> Optional[float]: fallback = self.config.getfloat( "play", "maxwait", fallback=None ) # pre 2.0 @@ -140,28 +146,28 @@ class Config: ) @property - def play_speed(self): + def play_speed(self) -> float: return self.config.getfloat("play", "speed", fallback=1.0) @property - def play_pause_key(self): + def play_pause_key(self) -> Any: return self.__get_key("play", "pause", " ") @property - def play_step_key(self): + def play_step_key(self) -> Any: return self.__get_key("play", "step", ".") @property - def notifications_enabled(self): + def notifications_enabled(self) -> bool: return self.config.getboolean( "notifications", "enabled", fallback=True ) @property - def notifications_command(self): + def notifications_command(self) -> Optional[str]: return self.config.get("notifications", "command", fallback=None) - def __get_key(self, section, name, default=None): + def __get_key(self, section: str, name: str, default: Any = None) -> Any: key = self.config.get(section, name + "_key", fallback=default) if key: @@ -170,22 +176,23 @@ class Config: if upper_key[0] == "C" and upper_key[1] == "-": return bytes([ord(upper_key[2]) - 0x40]) - else: - 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)" - ) - 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 @@ -205,7 +212,9 @@ def get_config_home(env=os.environ): 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 From 4d3478e7ec8426a7f2ac1d63ee62fe8c84941bf9 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 23:47:42 -0400 Subject: [PATCH 028/121] [typing] Annotate asciinema.term --- asciinema/term.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/asciinema/term.py b/asciinema/term.py index f3b54fb..7303675 100644 --- a/asciinema/term.py +++ b/asciinema/term.py @@ -1,42 +1,45 @@ import os import select import subprocess -import time -import tty +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, Tuple, Union -class raw(): - def __init__(self, fd): +class raw: + def __init__(self, fd: Union[IO[str], int]) -> None: self.fd = fd - self.restore = False + self.restore: bool = False + self.mode: Optional[List[Any]] = None - def __enter__(self): + def __enter__(self) -> None: try: self.mode = tty.tcgetattr(self.fd) - tty.setraw(self.fd) + setraw(self.fd) self.restore = True - except tty.error: # This is the same as termios.error + except tty.error: # this is `termios.error` pass - def __exit__(self, type, value, traceback): + def __exit__(self, type_: str, value: str, traceback: str) -> None: if self.restore: - # Give the terminal time to send answerbacks - time.sleep(0.01) + 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, timeout): +def read_blocking(fd: int, timeout: Any) -> bytes: if fd in select.select([fd], [], [], timeout)[0]: return os.read(fd, 1024) - return b'' + return b"" -def get_size(): +def get_size() -> Tuple[int, int]: try: return os.get_terminal_size() - except: + except: # pylint: disable=bare-except # noqa: E722 return ( - int(subprocess.check_output(['tput', 'cols'])), - int(subprocess.check_output(['tput', 'lines'])) + int(subprocess.check_output(["tput", "cols"])), + int(subprocess.check_output(["tput", "lines"])), ) From c1bfc8955bcc3628accbff1356a94f720745d0b3 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Mon, 1 Nov 2021 23:57:34 -0400 Subject: [PATCH 029/121] [typing] Annotate asciinema.pty_ --- asciinema/pty_.py | 64 ++++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index dcd4a90..3adf525 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -8,37 +8,44 @@ import signal import struct import termios import time +from typing import Any, Dict, List, Optional, Tuple from .term import raw +# pylint: disable=too-many-arguments,too-many-locals,too-many-statements 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 + command: Any, + writer: Any, + env: Any = None, + rec_stdin: bool = False, + time_offset: float = 0, + notifier: Any = None, + key_bindings: Optional[Dict[str, Any]] = None, +) -> None: + if env is None: + env = os.environ + if key_bindings is None: + key_bindings = {} + master_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 _notify(text): + def _notify(text: str) -> None: if notifier: notifier.notify(text) - def _set_pty_size(): + def _set_pty_size() -> None: """ 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. + # 1. Get the terminal size of the real terminal. + # 2. Set the same size 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) @@ -47,27 +54,28 @@ def record( fcntl.ioctl(master_fd, termios.TIOCSWINSZ, buf) - def _write_stdout(data): + def _write_stdout(data: Any) -> None: """Writes to stdout as if the child process had written the data.""" os.write(pty.STDOUT_FILENO, data) - def _handle_master_read(data): + def _handle_master_read(data: Any) -> None: """Handles new data on child process stdout.""" if not pause_time: + assert start_time is not None writer.write_stdout(time.time() - start_time, data) _write_stdout(data) - def _write_master(data): + def _write_master(data: Any) -> None: """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): + def _handle_stdin_read(data: Any) -> None: """Handles new data on child process stdin.""" nonlocal pause_time @@ -83,7 +91,8 @@ def record( if data == pause_key: if pause_time: - start_time = start_time + (time.time() - pause_time) + assert start_time is not None + start_time += time.time() - pause_time pause_time = None _notify("Resumed recording") else: @@ -95,15 +104,16 @@ def record( _write_master(data) if rec_stdin and not pause_time: + assert start_time is not None writer.write_stdin(time.time() - start_time, data) - def _signals(signal_list): + def _signals(signal_list: Any) -> List[Tuple[Any, Any]]: old_handlers = [] for sig, handler in signal_list: old_handlers.append((sig, signal.signal(sig, handler))) return old_handlers - def _copy(signal_fd): + def _copy(signal_fd: int) -> None: # pylint: disable=too-many-branches """Main select loop. Passes control to _master_read() or _stdin_read() @@ -114,11 +124,13 @@ def record( while True: try: - rfds, wfds, xfds = select.select(fds, [], []) + rfds, _, _ = select.select(fds, [], []) except OSError as e: # Python >= 3.3 if e.errno == errno.EINTR: continue - except select.error as e: # Python < 3.3 + # TODO: remove this if Python 3.7+ required + # Python < 3.3 + except select.error as e: # pylint: disable=duplicate-except if e.args[0] == 4: continue @@ -139,7 +151,7 @@ def record( if signal_fd in rfds: data = os.read(signal_fd, 1024) if data: - signals = struct.unpack("%uB" % len(data), data) + signals = struct.unpack(f"{len(data)}B", data) for sig in signals: if sig in [ signal.SIGCHLD, @@ -149,7 +161,7 @@ def record( ]: os.close(master_fd) return - elif sig == signal.SIGWINCH: + if sig == signal.SIGWINCH: _set_pty_size() pid, master_fd = pty.fork() From 2ec9de701b344c80119e2bceb7de82657a414810 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Tue, 2 Nov 2021 01:47:39 -0400 Subject: [PATCH 030/121] [typing] Annotate asciinema.__main__ --- asciinema/__main__.py | 60 ++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index c48cfaa..64905f1 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -2,45 +2,47 @@ import argparse import locale import os import sys +from typing import Any, Optional -import asciinema.config as config -from asciinema import __version__ -from asciinema.commands.auth import AuthCommand -from asciinema.commands.cat import CatCommand -from asciinema.commands.play import PlayCommand -from asciinema.commands.record import RecordCommand -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_float(value: str) -> float: + _value = float(value) + if _value <= 0.0: raise argparse.ArgumentTypeError("must be positive") - return value + return _value -def maybe_str(v): +def maybe_str(v: Any) -> Optional[str]: if v is not None: return str(v) + return None -def main(): +def main() -> Any: 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.stderr.write( + "asciinema needs an ASCII or UTF-8 character encoding to run. " + "Check the output of `locale` command.\n" ) - sys.exit(1) + 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( @@ -62,7 +64,7 @@ def main(): \x1b[1masciinema cat demo.cast\x1b[0m For help on a specific command run: - \x1b[1masciinema -h\x1b[0m""", + \x1b[1masciinema -h\x1b[0m""", # noqa: E501 formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( @@ -71,7 +73,7 @@ For help on a specific command run: subparsers = parser.add_subparsers() - # create the parser for the "rec" command + # create the parser for the `rec` command parser_rec = subparsers.add_parser("rec", help="Record terminal session") parser_rec.add_argument( "--stdin", @@ -140,7 +142,7 @@ For help on a specific command run: ) parser_rec.set_defaults(cmd=RecordCommand) - # create the parser for the "play" command + # create the parser for the `play` command parser_play = subparsers.add_parser("play", help="Replay terminal session") parser_play.add_argument( "-i", @@ -161,7 +163,7 @@ For help on a specific command run: ) parser_play.set_defaults(cmd=PlayCommand) - # create the parser for the "cat" command + # create the parser for the `cat` command parser_cat = subparsers.add_parser( "cat", help="Print full output of terminal session" ) @@ -170,7 +172,7 @@ For help on a specific command run: ) parser_cat.set_defaults(cmd=CatCommand) - # create the parser for the "upload" command + # create the parser for the `upload` command parser_upload = subparsers.add_parser( "upload", help="Upload locally saved terminal session to asciinema.org" ) @@ -179,7 +181,7 @@ For help on a specific command run: ) parser_upload.set_defaults(cmd=UploadCommand) - # create the parser for the "auth" command + # create the parser for the `auth` command parser_auth = subparsers.add_parser( "auth", help="Manage recordings on asciinema.org account" ) @@ -191,11 +193,11 @@ For help on a specific command run: 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() + sys.exit(main()) From 0e49741627e8b8d517b392b7a202ed9d1c65a303 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Wed, 3 Nov 2021 00:36:25 -0400 Subject: [PATCH 031/121] [typing] Annotate asciinema.player --- asciinema/player.py | 49 +++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/asciinema/player.py b/asciinema/player.py index 3670a8d..f7ebe50 100644 --- a/asciinema/player.py +++ b/asciinema/player.py @@ -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 .term 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: From 45c065e28dd2a1353c1554233bd5ab8fab1a3e6b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Wed, 3 Nov 2021 02:17:04 -0400 Subject: [PATCH 032/121] [typing] Annotate asciinema.recorder --- asciinema/recorder.py | 84 +++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 32894cf..b717c3c 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -1,33 +1,38 @@ import os import time +from typing import Any, Callable, Dict, Optional, Tuple, Type -from . import pty_ as pty # avoid +from . import pty_ as pty # avoid collisions with standard library `pty` from . import term 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: Any = None, + append: bool = False, + idle_time_limit: Optional[int] = None, + rec_stdin: bool = False, + title: Optional[str] = None, + metadata: Any = None, + command_env: Optional[Dict[Any, Any]] = None, + capture_env: Any = None, + writer: Type[w2] = v2.writer, + record_: Callable[..., None] = pty.record, + notifier: Any = None, + key_bindings: Optional[Dict[str, Any]] = 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() + if key_bindings is None: + key_bindings = {} + command_env["ASCIINEMA_REC"] = "1" if capture_env is None: @@ -35,7 +40,11 @@ def record( w, h = term.get_size() - full_metadata = {"width": w, "height": h, "timestamp": int(time.time())} + full_metadata: Dict[str, Any] = { + "width": w, + "height": h, + "timestamp": int(time.time()), + } full_metadata.update(metadata or {}) @@ -50,43 +59,48 @@ def record( if title: full_metadata["title"] = title - time_offset = 0 + time_offset: float = 0 - if append and os.stat(path).st_size > 0: - time_offset = v2.get_duration(path) + if append and os.stat(path_).st_size > 0: + assert time_offset is not None + time_offset = v2.get_duration(path_) - with async_writer(writer, path, full_metadata, append) as w: - with async_notifier(notifier) as n: - record( + with async_writer(writer, path_, full_metadata, append) as _writer: + with async_notifier(notifier) as _notifier: + record_( ["sh", "-c", command], - w, + _writer, command_env, rec_stdin, time_offset, - n, + _notifier, key_bindings, ) class async_writer(async_worker): - def __init__(self, writer, path, metadata, append=False): + def __init__( + self, writer: Type[w2], path_: str, metadata: Any, append: bool = False + ) -> None: async_worker.__init__(self) self.writer = writer - self.path = path + self.path = path_ self.metadata = metadata self.append = append - def write_stdin(self, ts, data): + def write_stdin(self, ts: float, data: Any) -> None: self.enqueue([ts, "i", data]) - def write_stdout(self, ts, data): + def write_stdout(self, ts: float, data: Any) -> None: self.enqueue([ts, "o", data]) - def run(self): + def run(self) -> None: with self.writer( self.path, metadata=self.metadata, append=self.append ) 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": @@ -96,18 +110,18 @@ class async_writer(async_worker): class async_notifier(async_worker): - def __init__(self, notifier): + def __init__(self, notifier: Any) -> None: async_worker.__init__(self) self.notifier = notifier - 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: + 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 From 1cf99d42187f76bb8c79ae4b01ef7a065376a9ef Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Wed, 3 Nov 2021 02:30:45 -0400 Subject: [PATCH 033/121] [test] Configure pylint --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 75eb62c..07efeb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,4 +31,7 @@ exclude = [] [tool.pylint."MESSAGES CONTROL"] disable = [ "invalid-name", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", ] From 205d247dc45b873deb95dabacf8d90f82af8437f Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Wed, 3 Nov 2021 02:32:02 -0400 Subject: [PATCH 034/121] [test] Format v2 test --- tests/asciicast/v2_test.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/asciicast/v2_test.py b/tests/asciicast/v2_test.py index b03d7f6..65f0a12 100644 --- a/tests/asciicast/v2_test.py +++ b/tests/asciicast/v2_test.py @@ -1,24 +1,29 @@ -from ..test_helper import Test -import asciinema.asciicast.v2 as v2 -import tempfile import json +import tempfile + +import asciinema.asciicast.v2 as v2 + +from ..test_helper import Test class TestWriter(Test): - def test_writing(self): _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, "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 + ) From 209ee86f470d682c152841d8f8d90a1ded2ee55a Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Wed, 3 Nov 2021 02:32:38 -0400 Subject: [PATCH 035/121] [test] Format test_helper --- tests/test_helper.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index 714e859..052fca0 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -1,4 +1,5 @@ import sys + try: from StringIO import StringIO except ImportError: @@ -19,7 +20,6 @@ def assert_not_printed(expected): class Test: - def setUp(self): global stdout self.real_stdout = sys.stdout @@ -30,7 +30,6 @@ class Test: class FakeClock: - def __init__(self, values): self.values = values self.n = 0 @@ -43,7 +42,6 @@ class FakeClock: class FakeAsciicast: - def __init__(self, cmd=None, title=None, stdout=None, meta_data=None): self.cmd = cmd self.title = title From 33116aa9dea4e76b8a124210d612f5b9971c589d Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Thu, 4 Nov 2021 01:32:58 -0400 Subject: [PATCH 036/121] Refactor asciinema.notifier --- asciinema/notifier.py | 117 +++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/asciinema/notifier.py b/asciinema/notifier.py index 833ac84..a2f197e 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -1,55 +1,68 @@ -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): - return shutil.which(self.cmd) is not None + def __init__(self, cmd: str) -> None: + self.cmd = cmd - def notify(self, text): - subprocess.run(self.args(text), capture_output=True) - # we don't want to print *ANYTHING* to the terminal - # so we capture and ignore all output - - def get_icon_path(self): - path = os.path.join( - os.path.dirname(os.path.realpath(__file__)), + @staticmethod + def get_icon_path() -> Optional[str]: + path_ = path.join( + path.dirname(path.realpath(__file__)), "data/icon-256x256.png", ) - if os.path.exists(path): - return path + 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: 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 + 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", + 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: @@ -62,47 +75,47 @@ class TerminalNotifier(Notifier): "-appIcon", icon_path, ] - else: - return [ - "terminal-notifier", - "-title", - "asciinema", - "-message", - text, - ] + return [ + "terminal-notifier", + "-title", + "asciinema", + "-message", + text, + ] class CustomCommandNotifier(Notifier): - def __init__(self, command): - Notifier.__init__(self) - self.command = command - - def notify(self, text): - args = ["/bin/sh", "-c", self.command] - env = os.environ.copy() + def env(self, text: str) -> Dict[str, str]: + icon_path = self.get_icon_path() + env = environ.copy() env["TEXT"] = text - env["ICON_PATH"] = self.get_icon_path() - subprocess.run(args, env=env, capture_output=True) + if icon_path is not None: + env["ICON_PATH"] = icon_path + return env + + 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) -> 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() From 82e659125a76846851d9efaad1ec2c6d23eb74ee Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Thu, 4 Nov 2021 02:28:08 -0400 Subject: [PATCH 037/121] [typing] Annotate asciinema.commands.auth --- asciinema/commands/auth.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/asciinema/commands/auth.py b/asciinema/commands/auth.py index f5636a4..1672e48 100644 --- a/asciinema/commands/auth.py +++ b/asciinema/commands/auth.py @@ -1,11 +1,14 @@ -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): + 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" From 721bd0faedc2019fd6b77a5e7bda1e957fab65c7 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Thu, 4 Nov 2021 22:07:05 -0400 Subject: [PATCH 038/121] [build] Run build target in VIRTUAL_ENV --- Makefile | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index ac50010..9d90f9b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ NAME := asciinema VERSION := $(shell python3 -c "import asciinema; print(asciinema.__version__)") +VIRTUAL_ENV ?= .venv + .PHONY: test test: test.unit test.integration @@ -34,13 +36,22 @@ tag: .tag.exists git tag -s -m "Releasing $(VERSION)" v$(VERSION) git push origin v$(VERSION) +.PHONY: .venv +.venv: + python3 -m venv $(VIRTUAL_ENV) .PHONY: .pip -.pip: - python3 -m pip install --user --upgrade --quiet build twine +.pip: .venv + . $(VIRTUAL_ENV)/bin/activate \ + && python3 -m pip install --upgrade build twine -build: - python3 -m build . +build: .pip + . $(VIRTUAL_ENV)/bin/activate \ + && python3 -m build . + +install: build + . $(VIRTUAL_ENV)/bin/activate \ + && python3 -m pip install . .PHONY: push push: .pip build From 6759e736603063a23e45ba3e01ef72fca21495ce Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:33:44 -0400 Subject: [PATCH 039/121] [typing] Annotate asciinema.commands.upload --- asciinema/commands/upload.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py index f04e80c..57af7e7 100644 --- a/asciinema/commands/upload.py +++ b/asciinema/commands/upload.py @@ -1,13 +1,16 @@ -from asciinema.api import APIError -from asciinema.commands.command import Command +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) From 4bd919a51f5b8051052024348101147f7486f141 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:33:58 -0400 Subject: [PATCH 040/121] [typing] Annotate asciinema.commands.record --- asciinema/commands/record.py | 50 ++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index d91c290..66e864d 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -1,17 +1,17 @@ import os import sys -import tempfile +from tempfile import NamedTemporaryFile +from typing import Any, Dict, Optional -import asciinema.asciicast.raw as raw -import asciinema.asciicast.v2 as v2 -import asciinema.notifier as notifier -import asciinema.recorder as recorder -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 @@ -34,7 +34,10 @@ class RecordCommand(Command): "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 @@ -44,9 +47,8 @@ class RecordCommand(Command): "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 not os.access(self.filename, os.W_OK): @@ -60,10 +62,12 @@ class RecordCommand(Command): elif os.stat(self.filename).st_size > 0 and not append: self.print_error(f"{self.filename} already exists, aborting") self.print_error( - "use --overwrite option if you want to overwrite existing recording" + "use --overwrite option " + "if you want to overwrite existing recording" ) self.print_error( - "use --append option if you want to append to existing recording" + "use --append option " + "if you want to append to existing recording" ) return 1 @@ -79,8 +83,12 @@ class RecordCommand(Command): """press 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: @@ -91,7 +99,7 @@ class RecordCommand(Command): title=self.title, idle_time_limit=self.idle_time_limit, command_env=self.env, - capture_env=vars, + capture_env=vars_, rec_stdin=self.rec_stdin, writer=self.writer, notifier=self.notifier, @@ -140,7 +148,5 @@ class RecordCommand(Command): 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 From 26ac438844a78ab582281d918c68e1c4b7632135 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:36:19 -0400 Subject: [PATCH 041/121] [typing] Annotate asciinema.__init__ --- asciinema/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index d0720a8..4cce582 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -7,18 +7,18 @@ if sys.version_info < (3, 7): raise ImportError("Python < 3.7 is unsupported.") # pylint: disable=wrong-import-position -from typing import Any +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, +def record_asciicast( # pylint: disable=too-many-arguments + path_: str, + command: Any = None, + append: bool = False, + idle_time_limit: Optional[int] = None, + rec_stdin: bool = False, + title: Optional[str] = None, metadata: Any = None, command_env: Any = None, capture_env: Any = None, From ecb2b60568573fffca750b6a882e727d931163fd Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:37:34 -0400 Subject: [PATCH 042/121] [typing] Annotate asciinema.commands.cat --- asciinema/commands/cat.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index 2348d51..42600df 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -1,23 +1,25 @@ import sys +from typing import Any, Dict -import asciinema.asciicast as asciicast -from asciinema.commands.command import Command -from asciinema.term import raw +from .. import asciicast +from ..config import Config +from ..term 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", "wt", 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(f"printing failed: {str(e)}") From d00a251ff41e61fd514739e540ad39dd32516edf Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:38:01 -0400 Subject: [PATCH 043/121] [typing] Annotate asciinema.commands.command --- asciinema/commands/command.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index 48c9593..2facc08 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -1,24 +1,32 @@ import sys +from typing import Any, Dict, TextIO -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, + file_: TextIO = sys.stdout, + end: str = "\n", + force: bool = False, + ) -> None: if not self.quiet or force: - print(text, file=file, end=end) + print(text, file=file_, end=end) - def print_info(self, text): + def print_info(self, text: str) -> None: self.print(f"asciinema: {text}") - def print_warning(self, text): + def print_warning(self, text: str) -> None: self.print(f"asciinema: {text}") - def print_error(self, text): + def print_error(self, text: str) -> None: self.print( - f"asciinema: {text}", file=sys.stderr, force=True + f"asciinema: {text}", file_=sys.stderr, force=True ) From e676a162db2be23694e73fc54007b3834cf9285b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:38:23 -0400 Subject: [PATCH 044/121] [typing] Annotate asciinema.commands.play --- asciinema/commands/play.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py index 68f605a..4f135e7 100644 --- a/asciinema/commands/play.py +++ b/asciinema/commands/play.py @@ -1,10 +1,19 @@ -import asciinema.asciicast as asciicast -from asciinema.commands.command import Command -from asciinema.player import Player +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 @@ -15,7 +24,7 @@ class PlayCommand(Command): "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( From 66e30bd9df1ca470a798117b1b7cf6efe4eaacd0 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 00:38:51 -0400 Subject: [PATCH 045/121] [typing] Add asciinema/py.typed --- asciinema/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 asciinema/py.typed diff --git a/asciinema/py.typed b/asciinema/py.typed new file mode 100644 index 0000000..e69de29 From 59376ece057b8e8bc08c5749a7f71bbeee91239b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 01:20:16 -0400 Subject: [PATCH 046/121] Revert "[test] Drop support for Python 3.6; upgrade CentOS" This reverts commit ab1fbb0c965b4b6d0646fa05026bba08f86e9638. --- .github/workflows/asciinema.yml | 1 + tests/distros/Dockerfile.centos | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 3933e98..2bfae0a 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -33,6 +33,7 @@ jobs: strategy: matrix: python: + - "3.6" - "3.7" - "3.8" - "3.9" diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos index 1a7b629..bc4fd7e 100644 --- a/tests/distros/Dockerfile.centos +++ b/tests/distros/Dockerfile.centos @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.3 -FROM docker.io/library/centos:8 +FROM docker.io/library/centos:7 -RUN yum install -y epel-release && yum install -y make python39 && yum clean all +RUN yum install -y epel-release && yum install -y make python36 && yum clean all WORKDIR /usr/src/app From 8eb9291923cb9eff7397dffed52be3ebdf235665 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 01:20:56 -0400 Subject: [PATCH 047/121] Support Python 3.6; remove annotations from __future__ CentOS 7 and Ubuntu 18.04 ship Python 3.6 by default. Python 3.6 supports f-strings and most type hinting syntax. --- asciinema/__init__.py | 4 ++-- asciinema/api.py | 12 +++++------- asciinema/asciicast/raw.py | 4 +--- asciinema/asciicast/v2.py | 4 +--- asciinema/async_worker.py | 31 +++++++++++++------------------ 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 4cce582..c83d1a5 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -3,8 +3,8 @@ import sys __author__ = "Marcin Kulik" __version__ = "2.0.2" -if sys.version_info < (3, 7): - raise ImportError("Python < 3.7 is unsupported.") +if sys.version_info < (3, 6): + raise ImportError("Python < 3.6 is unsupported.") # pylint: disable=wrong-import-position from typing import Any, Optional diff --git a/asciinema/api.py b/asciinema/api.py index a0eb383..6d11f36 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json import platform import re @@ -30,17 +28,17 @@ class Api: http_adapter if http_adapter is not None else URLLibHttpAdapter() ) - def hostname(self: Api) -> Optional[str]: + def hostname(self) -> Optional[str]: return urlparse(self.url).hostname - def auth_url(self: Api) -> str: + def auth_url(self) -> str: return f"{self.url}/connect/{self.install_id}" - def upload_url(self: Api) -> str: + def upload_url(self) -> str: return f"{self.url}/api/asciicasts" - def upload_asciicast(self: Api, path: str) -> Tuple[Any, Any]: - 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(), diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index 18e5016..ef9d082 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from os import path, stat from typing import IO, Any, Optional @@ -23,7 +21,7 @@ class writer: self.file: Optional[IO[Any]] = None self.metadata = metadata - def __enter__(self) -> writer: + def __enter__(self) -> Any: self.file = open(self.path, mode=self.mode, buffering=self.buffering) return self diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 10437a5..f2e411c 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import codecs import json from codecs import StreamReader @@ -112,7 +110,7 @@ class writer: self.mode = "w" self.header = build_header(width, height, metadata or {}) - def __enter__(self) -> writer: + def __enter__(self) -> Any: self.file = open( self.path, mode=self.mode, diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py index e9c72b7..70b29cf 100644 --- a/asciinema/async_worker.py +++ b/asciinema/async_worker.py @@ -1,22 +1,17 @@ -from __future__ import annotations +from typing import Any, Optional -from typing import TYPE_CHECKING, 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 Process, Queue, synchronize -if TYPE_CHECKING: - from multiprocessing import Process, Queue -else: - 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 Process, Queue, synchronize - - # pylint: disable=pointless-statement - lambda _=synchronize: None # avoid pruning import - except ImportError: - from queue import Queue - from threading import Thread as Process + # pylint: disable=pointless-statement + lambda _=synchronize: None # avoid pruning import +except ImportError: + from queue import Queue # type: ignore + from threading import Thread as Process # type: ignore class async_worker: @@ -24,7 +19,7 @@ class async_worker: self.queue: Queue[Any] = Queue() self.process: Optional[Process] = None - def __enter__(self) -> async_worker: + def __enter__(self) -> Any: self.process = Process(target=self.run) self.process.start() return self From cb405366624b7e3db02de2ff1944a634242cfb60 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 01:30:31 -0400 Subject: [PATCH 048/121] v2.2.0: Require Python 3.6+ --- asciinema/__init__.py | 2 +- setup.cfg | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index c83d1a5..2549b3a 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -1,7 +1,7 @@ import sys __author__ = "Marcin Kulik" -__version__ = "2.0.2" +__version__ = "2.2.0" if sys.version_info < (3, 6): raise ImportError("Python < 3.6 is unsupported.") diff --git a/setup.cfg b/setup.cfg index dcbc837..a0e7ca8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,6 @@ [metadata] name = asciinema -version = 2.0.2 -# does not work with interpolation in `download_url` below -#version = attr: asciinema.__version__ +version = 2.2.0 author = Marcin Kulik author_email = m@ku1ik.com url = https://asciinema.org @@ -22,14 +20,11 @@ classifiers = 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 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: System :: Shells Topic :: Terminals Topic :: Utilities' From bc90ca718839d0981309c15ffb5335529ae97cd5 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 01:37:24 -0400 Subject: [PATCH 049/121] Normalize user-agent header casing in asciinema.api --- asciinema/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/api.py b/asciinema/api.py index 6d11f36..c2344a2 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -61,7 +61,7 @@ class Api: return result, headers.get("Warning") def _headers(self) -> Dict[str, Union[Callable[[], str], str]]: - return {"user-Agent": self._user_agent, "accept": "application/json"} + return {"user-agent": self._user_agent, "accept": "application/json"} @property @staticmethod From daee7578ed62ca209585631ec43ef9e97a57ef70 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Fri, 5 Nov 2021 01:39:24 -0400 Subject: [PATCH 050/121] [build] Remove .mypy_cache with clean.all Makefile target --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9d90f9b..2700bf7 100644 --- a/Makefile +++ b/Makefile @@ -67,4 +67,9 @@ clean: rm -rf dist *.egg-info clean.all: clean - find . -type d -name __pycache__ -o -name .pytest_cache -exec rm -r "{}" + + find . \ + -type d \ + -name __pycache__ \ + -o -name .pytest_cache \ + -o -name .mypy_cache \ + -exec rm -r "{}" + From 9ccf4efd4d3babc4065be767b375727c7d739d3f Mon Sep 17 00:00:00 2001 From: Umar Getagazov Date: Thu, 4 Nov 2021 18:24:09 +0700 Subject: [PATCH 051/121] Replace mentions of freenode with libera.chat --- CONTRIBUTING.md | 4 ++-- man/asciinema.1.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7be73a..b4327ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,12 +27,12 @@ However, as this is an open-source project maintained by a small team of volunte ## 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. -If you need help then either join #asciinema IRC channel on freenode or drop us an email at support@asciinema.org. +If you need help then either join #asciinema IRC channel on Libera.Chat or drop us an email at support@asciinema.org. ## Reporting security issues diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 09a970c..01d11b1 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -323,7 +323,7 @@ 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) -* IRC: [Channel on Freenode](https://webchat.freenode.net/?channels=asciinema) +* IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema) * Twitter: [@asciinema](https://twitter.com/asciinema) From cb41544e578675c50351bf35b8a2f01464b5f404 Mon Sep 17 00:00:00 2001 From: Christian Felder Date: Fri, 3 Dec 2021 20:47:08 +0100 Subject: [PATCH 052/121] README: update version mentioned for base image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6d151d..2c5b601 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Then run it with: ### 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 and has the latest version of asciinema recorder pre-installed. docker pull asciinema/asciinema From 1428ff7243f2be43894f2af0b11641fb77c28e53 Mon Sep 17 00:00:00 2001 From: Abhimanyu Singh Date: Mon, 22 Nov 2021 01:14:00 +0530 Subject: [PATCH 053/121] Append Behavior Do not append if the file does not exit. --- asciinema/commands/record.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index b8f9423..5d1b105 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -59,6 +59,10 @@ class RecordCommand(Command): 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("%s does not exist, not appending" % self.filename) + + append = False if append: self.print_info("appending to asciicast at %s" % self.filename) From 1ed3e883cbcc46f30f8855f4067562a60180a958 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 13 Dec 2021 21:56:50 +0100 Subject: [PATCH 054/121] Remove unnecessary blank line --- asciinema/commands/record.py | 1 - 1 file changed, 1 deletion(-) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 5d1b105..60f98a1 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -61,7 +61,6 @@ class RecordCommand(Command): return 1 elif append: self.print_warning("%s does not exist, not appending" % self.filename) - append = False if append: From d6970b239a428a1ca58ea5b4ec0599992f121dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Mellstr=C3=B6m?= <11281108+harkabeeparolus@users.noreply.github.com> Date: Sat, 11 Dec 2021 23:14:34 +0100 Subject: [PATCH 055/121] Recommend pipx installation --- README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2c5b601..d2cda0d 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,14 @@ 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 + pipx install asciinema + +If you don't have pipx, install using pip with your preferred Python version: + + python3 -m pip install asciinema Record your first session: @@ -57,15 +62,21 @@ 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. + pipx install asciinema + +Or with pip (using your preferred Python version): + + python3 -m pip install asciinema + +Installing from [PyPI] is the recommended way of installation, which gives you the latest released version. ### Native packages From 0fdf7bb1e108a214bc8b701b00c77cb24df2c8c8 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sat, 12 Feb 2022 15:43:28 -0500 Subject: [PATCH 056/121] [build] Remove erroneous quote in setup.cfg --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index a0e7ca8..7e75a81 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ classifiers = Programming Language :: Python :: 3.10 Topic :: System :: Shells Topic :: Terminals - Topic :: Utilities' + Topic :: Utilities [options] include_package_data = True From c0f02e729e8f69f3ff42ce07dc24321d6346328c Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:27:37 -0500 Subject: [PATCH 057/121] [api] Fix logic error --- asciinema/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/api.py b/asciinema/api.py index c2344a2..1409b1a 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -50,7 +50,7 @@ class Api: except HTTPConnectionError as e: raise APIError(str(e)) from e - if status in (200, 201): + if status not in (200, 201): self._handle_error(status, body) if (headers.get("content-type") or "")[0:16] == "application/json": From 2a7870652cb78a7284176e458e6615e30174ed63 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:30:37 -0500 Subject: [PATCH 058/121] [style] Update pre-commit hooks --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a22094..3807edf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/PyCQA/isort - rev: 5.9.3 + rev: 5.10.1 hooks: - id: isort - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 22.1.0 hooks: - id: black - repo: https://github.com/adrienverge/yamllint From 8e8a586012c7fbe088066f11da49ad5dadc5c64b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:30:45 -0500 Subject: [PATCH 059/121] [pty] Remove exception for Python < 3.3 --- asciinema/pty_.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 3adf525..03dac97 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -128,11 +128,6 @@ def record( except OSError as e: # Python >= 3.3 if e.errno == errno.EINTR: continue - # TODO: remove this if Python 3.7+ required - # Python < 3.3 - except select.error as e: # pylint: disable=duplicate-except - if e.args[0] == 4: - continue if master_fd in rfds: data = os.read(master_fd, 1024) From aa244ab1053e87dd3a6722a379045355d39db111 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:31:58 -0500 Subject: [PATCH 060/121] [recorder] Remove redundant assertion --- asciinema/recorder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index b717c3c..69e8e07 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -62,7 +62,6 @@ def record( # pylint: disable=too-many-arguments,too-many-locals time_offset: float = 0 if append and os.stat(path_).st_size > 0: - assert time_offset is not None time_offset = v2.get_duration(path_) with async_writer(writer, path_, full_metadata, append) as _writer: From b7eafaa881e296f66c7eae734349b2f59090528f Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:34:19 -0500 Subject: [PATCH 061/121] [cat] Set /dev/tty open mode as read-only --- asciinema/commands/cat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index 42600df..e791548 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -14,7 +14,7 @@ class CatCommand(Command): def execute(self) -> int: try: - with open("/dev/tty", "wt", encoding="utf-8") as stdin: + 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(): From 811552d9108bfdd115324e781826270905a4df86 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:47:16 -0500 Subject: [PATCH 062/121] [command] Re-add escape codes converted by editor --- asciinema/commands/command.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index 2facc08..e956d50 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -21,12 +21,12 @@ class Command: print(text, file=file_, end=end) def print_info(self, text: str) -> None: - self.print(f"asciinema: {text}") + self.print(f"\x1b[0;32masciinema: {text}\x1b[0m") def print_warning(self, text: str) -> None: - self.print(f"asciinema: {text}") + self.print(f"\x1b[0;33masciinema: {text}\x1b[0m") def print_error(self, text: str) -> None: self.print( - f"asciinema: {text}", file_=sys.stderr, force=True + f"\x1b[0;31masciinema: {text}\x1b[0m", file_=sys.stderr, force=True ) From d78ae57f2fb0f1474b9244c89476fe67043c2f99 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:54:17 -0500 Subject: [PATCH 063/121] [style] Add additional pre-commit hooks --- .pre-commit-config.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3807edf..5afb297 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,8 +3,12 @@ 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 @@ -29,3 +33,8 @@ repos: - --remove-all-unused-imports - --remove-duplicate-keys - --remove-unused-variables + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v0.931" + hooks: + - id: mypy + exclude: tests From 8cd0a47e5af8ccd1486cecc625e9a87b6afc64df Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 10:57:11 -0500 Subject: [PATCH 064/121] [v1] Remove import try / except block --- asciinema/asciicast/v1.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/asciinema/asciicast/v1.py b/asciinema/asciicast/v1.py index 6be9103..a1f754b 100644 --- a/asciinema/asciicast/v1.py +++ b/asciinema/asciicast/v1.py @@ -1,14 +1,10 @@ import json from codecs import StreamReader +from json.decoder import JSONDecodeError from typing import Any, Dict, Generator, List, Optional, TextIO, Union from .events import to_absolute_time -try: - from json.decoder import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError # type: ignore - class LoadError(Exception): pass From da5ea404978afaa17c1c4f1fc4edc6ad522580eb Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:02:44 -0500 Subject: [PATCH 065/121] [v2] Remove Optional from Asciicast.__enter__ return annotation --- asciinema/asciicast/v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index f2e411c..5b24ff2 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -44,7 +44,7 @@ class open_from_file: self.first_line = first_line self.file = file - def __enter__(self) -> Optional[Asciicast]: + def __enter__(self) -> Asciicast: try: v2_header: Dict[str, Any] = json.loads(self.first_line) if v2_header.get("version") == 2: From bdeb3b18217642d65601fd65333c14793a5a8e86 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:12:11 -0500 Subject: [PATCH 066/121] [raw] Rename unused var for pylint --- asciinema/asciicast/raw.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index ef9d082..21e9e62 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -31,8 +31,7 @@ class writer: assert self.file is not None self.file.close() - def write_stdout(self, ts: float, data: Any) -> None: - _ = ts + def write_stdout(self, _ts: float, data: Any) -> None: assert self.file is not None self.file.write(data) From 47030f9b0ef327107d41a472c4f092068232003c Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:13:19 -0500 Subject: [PATCH 067/121] [v2] Remove redundant assertion --- asciinema/asciicast/v2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 5b24ff2..784221f 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -63,7 +63,6 @@ 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: - assert isinstance(a, Asciicast) last_frame = None for last_frame in a.stdout_events(): pass From 430788bacafda4e800416d6ba755498f41f19c61 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:20:52 -0500 Subject: [PATCH 068/121] [adapter] Fix HTTP Basic auth header encoding https://www.python.org/dev/peps/pep-0498/#no-binary-f-strings --- asciinema/urllib_http_adapter.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py index d09adb9..de80fe3 100644 --- a/asciinema/urllib_http_adapter.py +++ b/asciinema/urllib_http_adapter.py @@ -1,8 +1,8 @@ -import base64 import codecs import http 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 @@ -96,9 +96,10 @@ class URLLibHttpAdapter: # pylint: disable=too-few-public-methods headers["content-type"] = content_type if password: - auth = f"{username}:{password}" - encoded_auth = base64.b64encode(bytes(auth, "utf-8")) - headers["authorization"] = f"Basic {encoded_auth!r}" + 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") From 2127a69e45a75dee0c3f64620cc3dc94a049a6f6 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:32:51 -0500 Subject: [PATCH 069/121] [tests:distros] Update Alpine 3.14 => 3.15 https://alpinelinux.org/posts/Alpine-3.15.0-released.html --- tests/distros/Dockerfile.alpine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/distros/Dockerfile.alpine b/tests/distros/Dockerfile.alpine index bdc3fc8..9716325 100644 --- a/tests/distros/Dockerfile.alpine +++ b/tests/distros/Dockerfile.alpine @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.3 -FROM docker.io/library/alpine:3.14 +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 From 5546d7b0e65af8d9680b47a430baf07f970745b2 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:34:29 -0500 Subject: [PATCH 070/121] [tests:distros] Update Fedora 34 => 35 https://bugs.launchpad.net/ubuntu/+source/docker.io/+bug/1938908/comments/101 --- tests/distros/Dockerfile.fedora | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/distros/Dockerfile.fedora b/tests/distros/Dockerfile.fedora index e5abb51..3f4a923 100644 --- a/tests/distros/Dockerfile.fedora +++ b/tests/distros/Dockerfile.fedora @@ -1,8 +1,6 @@ # 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 +FROM registry.fedoraproject.org/fedora:35 RUN dnf install -y make python3 procps && dnf clean all From f8e4890944ae05a6d37ab91ac8bf7e269fee0b6a Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:41:58 -0500 Subject: [PATCH 071/121] [config] Replace concatenation with f-string --- asciinema/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/config.py b/asciinema/config.py index 02db07d..51718a2 100644 --- a/asciinema/config.py +++ b/asciinema/config.py @@ -168,7 +168,7 @@ class Config: return self.config.get("notifications", "command", fallback=None) def __get_key(self, section: str, name: str, default: Any = None) -> Any: - key = self.config.get(section, name + "_key", fallback=default) + key = self.config.get(section, f"{name}_key", fallback=default) if key: if len(key) == 3: From 733539feaa98f3c738b323fa95cda5b10259dfaa Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 11:42:28 -0500 Subject: [PATCH 072/121] [record] Replace %s formatting with f-string --- asciinema/commands/record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index a9cce62..18b26c5 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -72,7 +72,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes return 1 elif append: self.print_warning( - "%s does not exist, not appending" % self.filename + f"{self.filename} does not exist, not appending" ) append = False From 4ef8d32fc9f48bf90aff7910c100297b698ea77c Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 16:07:33 -0500 Subject: [PATCH 073/121] Revert "[tests:distros] Update Fedora 34 => 35" This reverts commit 5546d7b0e65af8d9680b47a430baf07f970745b2. The fix for the default Docker seccomp profile containing the clone() syscall wrapper from glibc 2.34 does not appear to be in the GitHub Actions `ubuntu-latest` virtual environment yet. * https://github.com/actions/virtual-environments * https://github.com/actions/virtual-environments/blob/releases/ubuntu20/20220207/images/linux/Ubuntu2004-Readme.md --- tests/distros/Dockerfile.fedora | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/distros/Dockerfile.fedora b/tests/distros/Dockerfile.fedora index 3f4a923..e5abb51 100644 --- a/tests/distros/Dockerfile.fedora +++ b/tests/distros/Dockerfile.fedora @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1.3 -FROM registry.fedoraproject.org/fedora:35 +# 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 From f984d2c2e16b8d2567c2f16949f69be2c63cddf2 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 16:42:04 -0500 Subject: [PATCH 074/121] [ci] Add pre-commit GitHub Action workflow https://github.com/pre-commit/action --- .github/workflows/pre-commit.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..e157632 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,14 @@ +--- +name: pre-commit +on: + - pull_request + - push +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.10" + - uses: pre-commit/action@v2.0.3 From f70702f490d441dc521bae52afd8bb1be7945629 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 16:43:18 -0500 Subject: [PATCH 075/121] [ci] Update default Python environment 3.9 => 3.10 --- .github/workflows/asciinema.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 2bfae0a..2ca74ca 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -14,7 +14,7 @@ jobs: - name: setup Python uses: actions/setup-python@v2 with: - python-version: "3.9" + python-version: "3.10" - name: install dependencies run: pip install build cmarkgfm pycodestyle twine - name: Run pycodestyle From df71dc8c374964ec6cf8d0b58aae3bcc17c31f2f Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 16:54:24 -0500 Subject: [PATCH 076/121] [doc] Remove trailing whitespace --- man/Makefile | 1 - man/asciinema.1 | 4 ++-- man/asciinema.1.md | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/man/Makefile b/man/Makefile index 21c46cb..c635dfc 100644 --- a/man/Makefile +++ b/man/Makefile @@ -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`" - diff --git a/man/asciinema.1 b/man/asciinema.1 index 874828c..f3c1c60 100644 --- a/man/asciinema.1 +++ b/man/asciinema.1 @@ -55,7 +55,7 @@ This can be used by your shell's config file (\f[C]\&.bashrc\f[], being recorded. .TP .B Available options: -\ +\ .RS .TP .B \f[C]\-\-stdin\f[] @@ -181,7 +181,7 @@ asciinema\ play\ dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii .fi .TP .B Available options: -\ +\ .RS .TP .B \f[C]\-i,\ \-\-idle\-time\-limit=\f[] diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 01d11b1..f16edd0 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -322,7 +322,7 @@ MORE RESOURCES More documentation is available on the asciicast.org website and its GitHub wiki: * Web: [asciinema.org/docs/](https://asciinema.org/docs/) -* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) +* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki) * IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema) * Twitter: [@asciinema](https://twitter.com/asciinema) @@ -335,4 +335,3 @@ asciinema's lead developer is Marcin Kulik. For a list of all contributors look here: This Manual Page was written by Marcin Kulik with help from Kurt Pfeifle. - From d7c5794b1eab8de8a0df7cc3cd571e030e41e908 Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 16:54:41 -0500 Subject: [PATCH 077/121] [tests] Add trailing newline to demo.json --- tests/demo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/demo.json b/tests/demo.json index c4464ca..68092ae 100644 --- a/tests/demo.json +++ b/tests/demo.json @@ -111,4 +111,4 @@ "\r\n" ] ] -} \ No newline at end of file +} From d536ea689cbabc0bcdbe11d1e583004580d61a2b Mon Sep 17 00:00:00 2001 From: Davis Schirmer Date: Sun, 13 Feb 2022 17:04:12 -0500 Subject: [PATCH 078/121] [doc] Update CONTRIBUTING --- CONTRIBUTING.md | 51 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4327ce..935ee78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +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 Libera.Chat. +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. -If you need help then either join #asciinema IRC channel on Libera.Chat or drop us an email at support@asciinema.org. +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 . ## 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 From 62d77dc9fb8907cd0bb98c0cbe47484a236b04d6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 13 Feb 2022 22:57:55 +0100 Subject: [PATCH 079/121] Automatically reopen named pipe (FIFO) and resume writing upon BrokenPipeError --- asciinema/asciicast/raw.py | 32 +++++++++++-------------- asciinema/asciicast/v2.py | 48 ++++++++++++++++++++++---------------- asciinema/async_worker.py | 5 ++++ asciinema/file_writer.py | 44 ++++++++++++++++++++++++++++++++++ asciinema/recorder.py | 27 +++++++++++++++++---- 5 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 asciinema/file_writer.py diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index 21e9e62..a6978df 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -1,40 +1,36 @@ -from os import path, stat -from typing import IO, Any, Optional +import os +from os import path +from typing import Any, Callable, Optional + +from ..file_writer import file_writer -class writer: +class writer(file_writer): def __init__( 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) + if ( - append and path.exists(path_) and stat(path_).st_size == 0 + 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: str = "ab" if append else "wb" - self.file: Optional[IO[Any]] = None self.metadata = metadata - def __enter__(self) -> Any: - self.file = open(self.path, mode=self.mode, buffering=self.buffering) - 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 write_stdout(self, _ts: float, data: Any) -> None: - assert self.file is not None - self.file.write(data) + self._write(data) # pylint: disable=no-self-use def write_stdin(self, ts: float, data: Any) -> None: pass + + def _open_file(self) -> None: + self.file = open(self.path, mode=self.mode, buffering=self.buffering) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 784221f..44f291c 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -1,9 +1,19 @@ import codecs import json from codecs import StreamReader -from io import IOBase from json.decoder import JSONDecodeError -from typing import IO, Any, Dict, Generator, List, Optional, TextIO, Union +from typing import ( + Any, + Callable, + Dict, + Generator, + List, + Optional, + TextIO, + Union, +) + +from ..file_writer import file_writer class LoadError(Exception): @@ -86,21 +96,22 @@ def build_header( return header -class writer: +class writer(file_writer): def __init__( # pylint: disable=too-many-arguments self, - path: str, + 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: - self.path = path + super().__init__(path_, on_error) + self.buffering = buffering self.stdin_decoder = codecs.getincrementaldecoder("UTF-8")("replace") self.stdout_decoder = codecs.getincrementaldecoder("UTF-8")("replace") - self.file: Optional[IO[Any]] = None if append: self.mode = "a" @@ -110,24 +121,13 @@ class writer: self.header = build_header(width, height, metadata or {}) def __enter__(self) -> Any: - self.file = open( - self.path, - mode=self.mode, - buffering=self.buffering, - encoding="utf-8", - ) + self._open_file() if self.header: self.__write_line(self.header) return self - def __exit__( - self, exc_type: str, exc_value: str, exc_traceback: str - ) -> None: - assert isinstance(self.file, IOBase) - self.file.close() - def write_stdout(self, ts: float, data: Union[str, bytes]) -> None: if isinstance(data, str): data = data.encode(encoding="utf-8", errors="strict") @@ -140,6 +140,14 @@ class writer: data = self.stdin_decoder.decode(data) self.__write_event(ts, "i", data) + def _open_file(self) -> None: + 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]) @@ -147,5 +155,5 @@ class writer: line = json.dumps( obj, ensure_ascii=False, indent=None, separators=(", ", ": ") ) - assert isinstance(self.file, IOBase) - self.file.write(f"{line}\n") + + self._write(f"{line}\n") diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py index 70b29cf..fbfcbb8 100644 --- a/asciinema/async_worker.py +++ b/asciinema/async_worker.py @@ -31,6 +31,11 @@ class async_worker: assert isinstance(self.process, Process) self.process.join() + 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) diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py new file mode 100644 index 0000000..a2c08d9 --- /dev/null +++ b/asciinema/file_writer.py @@ -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 + + 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) + except BrokenPipeError as e: + if stat.S_ISFIFO(os.stat(self.path).st_mode): + if self.on_error: + self.on_error("Broken pipe, reopening...") + + self._open_file() + + if self.on_error: + self.on_error("Output pipe reopened successfully") + + self.file.write(data) + else: + raise e diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 69e8e07..117f4f4 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -64,8 +64,14 @@ def record( # pylint: disable=too-many-arguments,too-many-locals if append and os.stat(path_).st_size > 0: time_offset = v2.get_duration(path_) - with async_writer(writer, path_, full_metadata, append) as _writer: - with async_notifier(notifier) as _notifier: + with async_notifier(notifier) as _notifier: + with async_writer( + writer, + path_, + full_metadata, + append, + _notifier.queue, + ) as _writer: record_( ["sh", "-c", command], _writer, @@ -79,13 +85,19 @@ def record( # pylint: disable=too-many-arguments,too-many-locals class async_writer(async_worker): def __init__( - self, writer: Type[w2], path_: str, metadata: Any, append: bool = False + self, + writer: Type[w2], + path_: str, + metadata: Any, + append: bool = False, + notifier_q: Any = None, ) -> None: async_worker.__init__(self) self.writer = writer self.path = path_ self.metadata = metadata self.append = append + self.notifier_q = notifier_q def write_stdin(self, ts: float, data: Any) -> None: self.enqueue([ts, "i", data]) @@ -95,7 +107,10 @@ class async_writer(async_worker): def run(self) -> None: with self.writer( - self.path, metadata=self.metadata, append=self.append + self.path, + metadata=self.metadata, + append=self.append, + on_error=self.__on_error, ) as w: event: Tuple[float, str, Any] for event in iter(self.queue.get, None): @@ -107,6 +122,10 @@ class async_writer(async_worker): elif etype == "i": w.write_stdin(ts, data) + def __on_error(self, reason: str) -> None: + if self.notifier_q: + self.notifier_q.put(reason) + class async_notifier(async_worker): def __init__(self, notifier: Any) -> None: From d16c2abf2517e3977861fed7a7b63bf3e8674a71 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 14 Feb 2022 22:47:46 +0100 Subject: [PATCH 080/121] Ignore mypy's check for None in hot code path --- asciinema/file_writer.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py index a2c08d9..463e98c 100644 --- a/asciinema/file_writer.py +++ b/asciinema/file_writer.py @@ -28,17 +28,16 @@ class file_writer: def _write(self, data: Any) -> None: try: - self.file.write(data) + self.file.write(data) # type: ignore except BrokenPipeError as e: if stat.S_ISFIFO(os.stat(self.path).st_mode): if self.on_error: self.on_error("Broken pipe, reopening...") - - self._open_file() - - if self.on_error: + self._open_file() self.on_error("Output pipe reopened successfully") + else: + self._open_file() - self.file.write(data) + self.file.write(data) # type: ignore else: raise e From fd09df89f62d4d786082d41b6ff2f36d4a346f5c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 16 Feb 2022 23:01:15 +0100 Subject: [PATCH 081/121] Option to override cols/rows of the recorded process This adds `--cols ` / `--rows ` options to `rec` command. This disables autodection of terminal size, and reports fake fixed number of columns/rows to the recorded process. --- README.md | 2 ++ asciinema/__main__.py | 20 ++++++++++++++++++ asciinema/commands/record.py | 4 ++++ asciinema/pty_.py | 24 ++++++++++----------- asciinema/recorder.py | 41 ++++++++++++++++++++++++++++++++---- asciinema/term.py | 13 +----------- man/asciinema.1.md | 6 ++++++ tests/pty_test.py | 2 +- 8 files changed, 83 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0a910ce..d333749 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ Available options: to `SHELL,TERM` - `-t, --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) diff --git a/asciinema/__main__.py b/asciinema/__main__.py index 64905f1..7c11928 100644 --- a/asciinema/__main__.py +++ b/asciinema/__main__.py @@ -12,6 +12,14 @@ from .commands.record import RecordCommand from .commands.upload import UploadCommand +def positive_int(value: str) -> int: + _value = int(value) + if _value <= 0: + raise argparse.ArgumentTypeError("must be positive") + + return _value + + def positive_float(value: str) -> float: _value = float(value) if _value <= 0.0: @@ -120,6 +128,18 @@ For help on a specific command run: 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", diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 18b26c5..cadc13b 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -21,6 +21,8 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes 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 @@ -109,6 +111,8 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes writer=self.writer, notifier=self.notifier, key_bindings=self.key_bindings, + cols_override=self.cols_override, + rows_override=self.rows_override, ) except v2.LoadError: self.print_error( diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 03dac97..ee0008f 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -8,7 +8,7 @@ import signal import struct import termios import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from .term import raw @@ -17,11 +17,14 @@ from .term import raw def record( command: Any, writer: Any, + get_tty_size: Callable[[], Tuple[int, int]], env: Any = None, rec_stdin: bool = False, time_offset: float = 0, notifier: Any = None, key_bindings: Optional[Dict[str, Any]] = None, + tty_stdin_fd: int = pty.STDIN_FILENO, + tty_stdout_fd: int = pty.STDOUT_FILENO, ) -> None: if env is None: env = os.environ @@ -46,18 +49,15 @@ def record( # 1. Get the terminal size of the real terminal. # 2. Set the same size 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]) + cols, rows = get_tty_size() + buf = array.array("h", [rows, cols, 0, 0]) fcntl.ioctl(master_fd, termios.TIOCSWINSZ, buf) def _write_stdout(data: Any) -> None: """Writes to stdout as if the child process had written the data.""" - os.write(pty.STDOUT_FILENO, data) + os.write(tty_stdout_fd, data) def _handle_master_read(data: Any) -> None: """Handles new data on child process stdout.""" @@ -120,7 +120,7 @@ def record( when new data arrives. """ - fds = [master_fd, pty.STDIN_FILENO, signal_fd] + fds = [master_fd, tty_stdin_fd, signal_fd] while True: try: @@ -136,10 +136,10 @@ def record( else: _handle_master_read(data) - if pty.STDIN_FILENO in rfds: - data = os.read(pty.STDIN_FILENO, 1024) + if tty_stdin_fd in rfds: + data = os.read(tty_stdin_fd, 1024) if not data: - fds.remove(pty.STDIN_FILENO) + fds.remove(tty_stdin_fd) else: _handle_stdin_read(data) @@ -188,7 +188,7 @@ def record( start_time = time.time() - time_offset - with raw(pty.STDIN_FILENO): + with raw(tty_stdin_fd): try: _copy(pipe_r) except (IOError, OSError): diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 117f4f4..0d08493 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -3,7 +3,6 @@ import time from typing import Any, Callable, Dict, Optional, Tuple, Type from . import pty_ as pty # avoid collisions with standard library `pty` -from . import term from .asciicast import v2 from .asciicast.v2 import writer as w2 from .async_worker import async_worker @@ -23,6 +22,8 @@ def record( # pylint: disable=too-many-arguments,too-many-locals record_: Callable[..., None] = pty.record, notifier: Any = 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", "sh") @@ -38,11 +39,16 @@ def record( # pylint: disable=too-many-arguments,too-many-locals if capture_env is None: capture_env = ["SHELL", "TERM"] - w, h = term.get_size() + tty_stdin_fd = 0 + tty_stdout_fd = 1 + + get_tty_size = _get_tty_size(tty_stdout_fd, cols_override, rows_override) + + cols, rows = get_tty_size() full_metadata: Dict[str, Any] = { - "width": w, - "height": h, + "width": cols, + "height": rows, "timestamp": int(time.time()), } @@ -75,11 +81,14 @@ def record( # pylint: disable=too-many-arguments,too-many-locals record_( ["sh", "-c", command], _writer, + get_tty_size, command_env, rec_stdin, time_offset, _notifier, key_bindings, + tty_stdin_fd=tty_stdin_fd, + tty_stdout_fd=tty_stdout_fd, ) @@ -143,3 +152,27 @@ class async_notifier(async_worker): # 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 diff --git a/asciinema/term.py b/asciinema/term.py index 7303675..0e851cd 100644 --- a/asciinema/term.py +++ b/asciinema/term.py @@ -1,10 +1,9 @@ import os import select -import subprocess 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, Tuple, Union +from typing import IO, Any, List, Optional, Union class raw: @@ -33,13 +32,3 @@ def read_blocking(fd: int, timeout: Any) -> bytes: return os.read(fd, 1024) return b"" - - -def get_size() -> Tuple[int, int]: - try: - return os.get_terminal_size() - except: # pylint: disable=bare-except # noqa: E722 - return ( - int(subprocess.check_output(["tput", "cols"])), - int(subprocess.check_output(["tput", "lines"])), - ) diff --git a/man/asciinema.1.md b/man/asciinema.1.md index f16edd0..39ad08c 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -93,6 +93,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) diff --git a/tests/pty_test.py b/tests/pty_test.py index df7aa62..49fb93c 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -44,6 +44,6 @@ class TestRecord(Test): "; sys.stdout.write('bar')" ), ] - asciinema.pty_.record(command, output) + asciinema.pty_.record(command, output, lambda: (80, 24)) assert output.data == [b"foo", b"bar"] From 760a7152e24962f255311b0ed57a4d33cc9b85ee Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Thu, 17 Feb 2022 22:52:39 +0100 Subject: [PATCH 082/121] Simplify async_writer --- asciinema/recorder.py | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 0d08493..371a6be 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -71,13 +71,11 @@ def record( # pylint: disable=too-many-arguments,too-many-locals time_offset = v2.get_duration(path_) with async_notifier(notifier) as _notifier: - with async_writer( - writer, - path_, - full_metadata, - append, - _notifier.queue, - ) as _writer: + sync_writer = writer( + path_, full_metadata, append, on_error=_notifier.queue.put + ) + + with async_writer(sync_writer) as _writer: record_( ["sh", "-c", command], _writer, @@ -93,20 +91,9 @@ def record( # pylint: disable=too-many-arguments,too-many-locals class async_writer(async_worker): - def __init__( - self, - writer: Type[w2], - path_: str, - metadata: Any, - append: bool = False, - notifier_q: Any = None, - ) -> None: + def __init__(self, writer: w2) -> None: async_worker.__init__(self) self.writer = writer - self.path = path_ - self.metadata = metadata - self.append = append - self.notifier_q = notifier_q def write_stdin(self, ts: float, data: Any) -> None: self.enqueue([ts, "i", data]) @@ -115,12 +102,7 @@ class async_writer(async_worker): self.enqueue([ts, "o", data]) def run(self) -> None: - with self.writer( - self.path, - metadata=self.metadata, - append=self.append, - on_error=self.__on_error, - ) as w: + with self.writer as w: event: Tuple[float, str, Any] for event in iter(self.queue.get, None): assert event is not None @@ -131,10 +113,6 @@ class async_writer(async_worker): elif etype == "i": w.write_stdin(ts, data) - def __on_error(self, reason: str) -> None: - if self.notifier_q: - self.notifier_q.put(reason) - class async_notifier(async_worker): def __init__(self, notifier: Any) -> None: From 59f8a40387588a658904c6d129be5c3392931ac9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Fri, 18 Feb 2022 20:22:27 +0100 Subject: [PATCH 083/121] Handle time offset in async_writer instead of pty --- asciinema/pty_.py | 3 +-- asciinema/recorder.py | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index ee0008f..28b57b4 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -20,7 +20,6 @@ def record( get_tty_size: Callable[[], Tuple[int, int]], env: Any = None, rec_stdin: bool = False, - time_offset: float = 0, notifier: Any = None, key_bindings: Optional[Dict[str, Any]] = None, tty_stdin_fd: int = pty.STDIN_FILENO, @@ -186,7 +185,7 @@ def record( _set_pty_size() - start_time = time.time() - time_offset + start_time = time.time() with raw(tty_stdin_fd): try: diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 371a6be..79465b6 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -75,14 +75,13 @@ def record( # pylint: disable=too-many-arguments,too-many-locals path_, full_metadata, append, on_error=_notifier.queue.put ) - with async_writer(sync_writer) as _writer: + with async_writer(sync_writer, time_offset) as _writer: record_( ["sh", "-c", command], _writer, get_tty_size, command_env, rec_stdin, - time_offset, _notifier, key_bindings, tty_stdin_fd=tty_stdin_fd, @@ -91,9 +90,10 @@ def record( # pylint: disable=too-many-arguments,too-many-locals class async_writer(async_worker): - def __init__(self, writer: w2) -> None: + def __init__(self, writer: w2, time_offset: float) -> None: async_worker.__init__(self) self.writer = writer + self.time_offset = time_offset def write_stdin(self, ts: float, data: Any) -> None: self.enqueue([ts, "i", data]) @@ -109,9 +109,9 @@ class async_writer(async_worker): ts, etype, data = event if etype == "o": - w.write_stdout(ts, data) + w.write_stdout(self.time_offset + ts, data) elif etype == "i": - w.write_stdin(ts, data) + w.write_stdin(self.time_offset + ts, data) class async_notifier(async_worker): From e86dbc9118b2893e88cdcc3c86f658161037e0cb Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Fri, 18 Feb 2022 20:40:16 +0100 Subject: [PATCH 084/121] Handle conditional stdin recording in async_writer instead of pty --- asciinema/__init__.py | 4 ++-- asciinema/commands/record.py | 4 ++-- asciinema/pty_.py | 3 +-- asciinema/recorder.py | 13 ++++++++----- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 2549b3a..84c044e 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -17,7 +17,7 @@ def record_asciicast( # pylint: disable=too-many-arguments command: Any = None, append: bool = False, idle_time_limit: Optional[int] = None, - rec_stdin: bool = False, + record_stdin: bool = False, title: Optional[str] = None, metadata: Any = None, command_env: Any = None, @@ -28,7 +28,7 @@ def record_asciicast( # pylint: disable=too-many-arguments 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, diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index cadc13b..1b41b75 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -15,7 +15,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes 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 @@ -107,7 +107,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes idle_time_limit=self.idle_time_limit, command_env=self.env, capture_env=vars_, - rec_stdin=self.rec_stdin, + record_stdin=self.record_stdin, writer=self.writer, notifier=self.notifier, key_bindings=self.key_bindings, diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 28b57b4..0813d33 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -19,7 +19,6 @@ def record( writer: Any, get_tty_size: Callable[[], Tuple[int, int]], env: Any = None, - rec_stdin: bool = False, notifier: Any = None, key_bindings: Optional[Dict[str, Any]] = None, tty_stdin_fd: int = pty.STDIN_FILENO, @@ -102,7 +101,7 @@ def record( _write_master(data) - if rec_stdin and not pause_time: + if not pause_time: assert start_time is not None writer.write_stdin(time.time() - start_time, data) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 79465b6..c7fdb6a 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -13,7 +13,7 @@ def record( # pylint: disable=too-many-arguments,too-many-locals command: Any = None, append: bool = False, idle_time_limit: Optional[int] = None, - rec_stdin: bool = False, + record_stdin: bool = False, title: Optional[str] = None, metadata: Any = None, command_env: Optional[Dict[Any, Any]] = None, @@ -75,13 +75,12 @@ def record( # pylint: disable=too-many-arguments,too-many-locals path_, full_metadata, append, on_error=_notifier.queue.put ) - with async_writer(sync_writer, time_offset) as _writer: + with async_writer(sync_writer, time_offset, record_stdin) as _writer: record_( ["sh", "-c", command], _writer, get_tty_size, command_env, - rec_stdin, _notifier, key_bindings, tty_stdin_fd=tty_stdin_fd, @@ -90,13 +89,17 @@ def record( # pylint: disable=too-many-arguments,too-many-locals class async_writer(async_worker): - def __init__(self, writer: w2, time_offset: float) -> None: + def __init__( + self, writer: w2, time_offset: float, record_stdin: bool + ) -> None: async_worker.__init__(self) self.writer = writer self.time_offset = time_offset + self.record_stdin = record_stdin def write_stdin(self, ts: float, data: Any) -> None: - self.enqueue([ts, "i", data]) + if self.record_stdin: + self.enqueue([ts, "i", data]) def write_stdout(self, ts: float, data: Any) -> None: self.enqueue([ts, "o", data]) From ed017ee41fbd84b60ccb96f64a0db5e941b1c365 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Fri, 18 Feb 2022 21:13:54 +0100 Subject: [PATCH 085/121] Less defensive programming, rename test mock class --- asciinema/commands/record.py | 2 +- asciinema/notifier.py | 2 +- asciinema/pty_.py | 18 +++++------------- asciinema/recorder.py | 15 +++++++-------- tests/pty_test.py | 11 +++++++---- 5 files changed, 21 insertions(+), 27 deletions(-) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 1b41b75..81af8ea 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -109,7 +109,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes capture_env=vars_, record_stdin=self.record_stdin, writer=self.writer, - notifier=self.notifier, + notify=self.notifier.notify, key_bindings=self.key_bindings, cols_override=self.cols_override, rows_override=self.rows_override, diff --git a/asciinema/notifier.py b/asciinema/notifier.py index a2f197e..9148966 100644 --- a/asciinema/notifier.py +++ b/asciinema/notifier.py @@ -102,7 +102,7 @@ class CustomCommandNotifier(Notifier): class NoopNotifier: # pylint: disable=too-few-public-methods - def notify(self) -> None: + def notify(self, text: str) -> None: pass diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 0813d33..4c07b61 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -16,18 +16,14 @@ from .term import raw # 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]], - env: Any = None, - notifier: Any = None, - key_bindings: Optional[Dict[str, Any]] = None, + notify: Callable[[str], None], + key_bindings: Dict[str, Any], tty_stdin_fd: int = pty.STDIN_FILENO, tty_stdout_fd: int = pty.STDOUT_FILENO, ) -> None: - if env is None: - env = os.environ - if key_bindings is None: - key_bindings = {} master_fd: Any = None start_time: Optional[float] = None pause_time: Optional[float] = None @@ -35,10 +31,6 @@ def record( prefix_key = key_bindings.get("prefix") pause_key = key_bindings.get("pause") - def _notify(text: str) -> None: - if notifier: - notifier.notify(text) - def _set_pty_size() -> None: """ Sets the window size of the child pty based on the window size @@ -92,10 +84,10 @@ def record( assert start_time is not None start_time += time.time() - pause_time pause_time = None - _notify("Resumed recording") + notify("Resumed recording") else: pause_time = time.time() - _notify("Paused recording") + notify("Paused recording") return diff --git a/asciinema/recorder.py b/asciinema/recorder.py index c7fdb6a..4b0e5f7 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -20,7 +20,7 @@ def record( # pylint: disable=too-many-arguments,too-many-locals capture_env: Any = None, writer: Type[w2] = v2.writer, record_: Callable[..., None] = pty.record, - notifier: Any = None, + notify: Callable[[str], None] = lambda _: None, key_bindings: Optional[Dict[str, Any]] = None, cols_override: Optional[int] = None, rows_override: Optional[int] = None, @@ -70,7 +70,7 @@ def record( # pylint: disable=too-many-arguments,too-many-locals if append and os.stat(path_).st_size > 0: time_offset = v2.get_duration(path_) - with async_notifier(notifier) as _notifier: + with async_notifier(notify) as _notifier: sync_writer = writer( path_, full_metadata, append, on_error=_notifier.queue.put ) @@ -78,10 +78,10 @@ def record( # pylint: disable=too-many-arguments,too-many-locals with async_writer(sync_writer, time_offset, record_stdin) as _writer: record_( ["sh", "-c", command], + command_env, _writer, get_tty_size, - command_env, - _notifier, + _notifier.notify, key_bindings, tty_stdin_fd=tty_stdin_fd, tty_stdout_fd=tty_stdout_fd, @@ -118,17 +118,16 @@ class async_writer(async_worker): class async_notifier(async_worker): - def __init__(self, notifier: Any) -> None: + def __init__(self, notify: Callable[[str], None]) -> None: async_worker.__init__(self) - self.notifier = notifier + self._notify = notify def notify(self, text: str) -> None: self.enqueue(text) def perform(self, text: str) -> None: try: - if self.notifier: - self.notifier.notify(text) + 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 diff --git a/tests/pty_test.py b/tests/pty_test.py index 49fb93c..56cbaf8 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -6,7 +6,7 @@ import asciinema.pty_ from .test_helper import Test -class FakeStdout: +class Writer: def __init__(self): self.data = [] @@ -30,7 +30,7 @@ class TestRecord(Test): self.real_os_write(fd, data) def test_record_command_writes_to_stdout(self): - output = FakeStdout() + writer = Writer() command = [ "python3", @@ -44,6 +44,9 @@ class TestRecord(Test): "; sys.stdout.write('bar')" ), ] - asciinema.pty_.record(command, output, lambda: (80, 24)) - assert output.data == [b"foo", b"bar"] + asciinema.pty_.record( + command, {}, writer, lambda: (80, 24), lambda s: None, {} + ) + + assert writer.data == [b"foo", b"bar"] From 0a3e5522ed8feae7b1a3a909c16840531034e8f9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Fri, 18 Feb 2022 21:25:24 +0100 Subject: [PATCH 086/121] Use notifier's public notify method for file writer --- asciinema/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 4b0e5f7..99d3dee 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -72,7 +72,7 @@ def record( # pylint: disable=too-many-arguments,too-many-locals with async_notifier(notify) as _notifier: sync_writer = writer( - path_, full_metadata, append, on_error=_notifier.queue.put + path_, full_metadata, append, on_error=_notifier.notify ) with async_writer(sync_writer, time_offset, record_stdin) as _writer: From 24dba63605604947828e062a3f947f60a1738905 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 19 Feb 2022 13:04:25 +0100 Subject: [PATCH 087/121] Typing improvements --- asciinema/__init__.py | 2 -- asciinema/recorder.py | 11 +++++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/asciinema/__init__.py b/asciinema/__init__.py index 84c044e..7f2b6ef 100644 --- a/asciinema/__init__.py +++ b/asciinema/__init__.py @@ -19,7 +19,6 @@ def record_asciicast( # pylint: disable=too-many-arguments idle_time_limit: Optional[int] = None, record_stdin: bool = False, title: Optional[str] = None, - metadata: Any = None, command_env: Any = None, capture_env: Any = None, ) -> None: @@ -30,7 +29,6 @@ def record_asciicast( # pylint: disable=too-many-arguments idle_time_limit=idle_time_limit, record_stdin=record_stdin, title=title, - metadata=metadata, command_env=command_env, capture_env=capture_env, ) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 99d3dee..82e41c7 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -1,6 +1,6 @@ import os import time -from typing import Any, Callable, Dict, Optional, Tuple, Type +from typing import Any, Callable, Dict, List, Optional, Tuple, Type from . import pty_ as pty # avoid collisions with standard library `pty` from .asciicast import v2 @@ -10,14 +10,13 @@ from .async_worker import async_worker def record( # pylint: disable=too-many-arguments,too-many-locals path_: str, - command: Any = None, + command: Optional[str] = None, append: bool = False, - idle_time_limit: Optional[int] = None, + idle_time_limit: Optional[float] = None, record_stdin: bool = False, title: Optional[str] = None, - metadata: Any = None, - command_env: Optional[Dict[Any, Any]] = None, - capture_env: Any = 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, From a946d3e7a029e90dcf8f25ede9ddc10c6207c8e0 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 19 Feb 2022 13:09:25 +0100 Subject: [PATCH 088/121] Dedicated function to build metadata --- asciinema/recorder.py | 58 +++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 82e41c7..eac2809 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -41,37 +41,22 @@ def record( # pylint: disable=too-many-arguments,too-many-locals tty_stdin_fd = 0 tty_stdout_fd = 1 - get_tty_size = _get_tty_size(tty_stdout_fd, cols_override, rows_override) - - cols, rows = get_tty_size() - - full_metadata: Dict[str, Any] = { - "width": cols, - "height": rows, - "timestamp": int(time.time()), - } - - full_metadata.update(metadata or {}) - - if idle_time_limit is not None: - full_metadata["idle_time_limit"] = idle_time_limit - - if capture_env: - full_metadata["env"] = { - var: command_env.get(var) for var in capture_env - } - - if title: - full_metadata["title"] = title - time_offset: float = 0 if append and os.stat(path_).st_size > 0: time_offset = v2.get_duration(path_) with 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 + ) + sync_writer = writer( - path_, full_metadata, append, on_error=_notifier.notify + path_, metadata, append, on_error=_notifier.notify ) with async_writer(sync_writer, time_offset, record_stdin) as _writer: @@ -87,6 +72,31 @@ def record( # pylint: disable=too-many-arguments,too-many-locals ) +def build_metadata( + 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: w2, time_offset: float, record_stdin: bool From 6a33b6a0e66a3c8efec68dadb4f16ebfb633db05 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 20 Feb 2022 11:48:14 +0100 Subject: [PATCH 089/121] Explicitly use tty for input/output instead of process' stdin/stdout --- asciinema/recorder.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index eac2809..fd8d20b 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -1,6 +1,6 @@ import os import time -from typing import Any, Callable, Dict, List, Optional, Tuple, Type +from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type from . import pty_ as pty # avoid collisions with standard library `pty` from .asciicast import v2 @@ -38,15 +38,14 @@ def record( # pylint: disable=too-many-arguments,too-many-locals if capture_env is None: capture_env = ["SHELL", "TERM"] - tty_stdin_fd = 0 - tty_stdout_fd = 1 - time_offset: float = 0 if append and os.stat(path_).st_size > 0: time_offset = v2.get_duration(path_) - with async_notifier(notify) as _notifier: + 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 ) @@ -72,6 +71,31 @@ def record( # pylint: disable=too-many-arguments,too-many-locals ) +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", "r") + except OSError: + self.stdin_file = open("/dev/null", "r") + + try: + self.stdout_file = open("/dev/tty", "w") + except OSError: + self.stdout_file = open("/dev/null", "w") + + 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( cols: int, rows: int, From 81cf52306be90476df3ae11352f58b9268d0b518 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 20 Feb 2022 12:32:16 +0100 Subject: [PATCH 090/121] Forward asciinema's stdin to recorded process --- asciinema/pty_.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 4c07b61..ab22da6 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -112,6 +112,11 @@ def record( fds = [master_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, [], []) @@ -133,6 +138,13 @@ def record( 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: From bc7bbb3c7829ac6d69993ebb9cc6ff418d20773d Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 20 Feb 2022 12:39:08 +0100 Subject: [PATCH 091/121] Don't use colors when asciinema's stdout/stderr is not a tty --- asciinema/commands/command.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index e956d50..8036de1 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -1,3 +1,4 @@ +import os import sys from typing import Any, Dict, TextIO @@ -21,12 +22,23 @@ class Command: print(text, file=file_, end=end) def print_info(self, text: str) -> None: - self.print(f"\x1b[0;32masciinema: {text}\x1b[0m") + if os.isatty(sys.stdout.fileno()): + self.print(f"\x1b[0;32masciinema: {text}\x1b[0m") + else: + self.print(f"asciinema: {text}") def print_warning(self, text: str) -> None: - self.print(f"\x1b[0;33masciinema: {text}\x1b[0m") + if os.isatty(sys.stdout.fileno()): + self.print(f"\x1b[0;33masciinema: {text}\x1b[0m") + else: + self.print(f"asciinema: {text}") def print_error(self, text: str) -> None: - self.print( - f"\x1b[0;31masciinema: {text}\x1b[0m", file_=sys.stderr, force=True - ) + if os.isatty(sys.stderr.fileno()): + self.print( + f"\x1b[0;31masciinema: {text}\x1b[0m", + file_=sys.stderr, + force=True, + ) + else: + self.print(f"asciinema: {text}", file_=sys.stderr, force=True) From c9f446ea87a715b752010d0e9a5a9a8eb60f6679 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 20 Feb 2022 16:03:13 +0100 Subject: [PATCH 092/121] Fix type errors after recent major typing refactoring --- asciinema/api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/asciinema/api.py b/asciinema/api.py index 1409b1a..61ac437 100644 --- a/asciinema/api.py +++ b/asciinema/api.py @@ -61,9 +61,8 @@ class Api: return result, headers.get("Warning") def _headers(self) -> Dict[str, Union[Callable[[], str], str]]: - return {"user-agent": self._user_agent, "accept": "application/json"} + return {"user-agent": self._user_agent(), "accept": "application/json"} - @property @staticmethod def _user_agent() -> str: os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform()) @@ -74,9 +73,9 @@ class Api: ) @staticmethod - def _handle_error(status: int, body: str) -> None: + def _handle_error(status: int, body: bytes) -> None: errors = { - 400: f"Invalid request: {body}", + 400: f"Invalid request: {body.decode('utf-8', 'replace')}", 401: "Invalid or revoked install ID", 404: ( "API endpoint not found. " @@ -84,7 +83,7 @@ class Api: "Please upgrade to the latest version." ), 413: "Sorry, your asciicast is too big.", - 422: f"Invalid asciicast: {body}", + 422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}", 503: "The server is down for maintenance. Try again in a minute.", } From 255c254fb2944f15adac7a84bf890cfa45509d3c Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Mon, 21 Feb 2022 20:42:54 +0100 Subject: [PATCH 093/121] Refactor pty module --- asciinema/pty_.py | 164 ++++++++++++++++++++-------------------------- 1 file changed, 72 insertions(+), 92 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index ab22da6..78e5cb7 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -12,6 +12,12 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from .term import raw +EXIT_SIGNALS = [ + signal.SIGHUP, + signal.SIGTERM, + signal.SIGQUIT, +] + # pylint: disable=too-many-arguments,too-many-locals,too-many-statements def record( @@ -24,50 +30,26 @@ def record( tty_stdin_fd: int = pty.STDIN_FILENO, tty_stdout_fd: int = pty.STDOUT_FILENO, ) -> None: - master_fd: Any = 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: - """ - Sets the window size of the child pty based on the window size - of our own controlling terminal. - """ - - # 1. Get the terminal size of the real terminal. - # 2. Set the same size on the pseudoterminal. - + def set_pty_size() -> None: cols, rows = get_tty_size() buf = array.array("h", [rows, cols, 0, 0]) - fcntl.ioctl(master_fd, termios.TIOCSWINSZ, buf) - - def _write_stdout(data: Any) -> None: - """Writes to stdout as if the child process had written the data.""" + fcntl.ioctl(pty_fd, termios.TIOCSWINSZ, buf) + def handle_master_read(data: Any) -> None: os.write(tty_stdout_fd, data) - def _handle_master_read(data: Any) -> None: - """Handles new data on child process stdout.""" - if not pause_time: assert start_time is not None writer.write_stdout(time.time() - start_time, data) - _write_stdout(data) - - def _write_master(data: Any) -> None: - """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: Any) -> None: - """Handles new data on child process stdin.""" - + def handle_stdin_read(data: Any) -> None: nonlocal pause_time nonlocal start_time nonlocal prefix_mode @@ -91,27 +73,16 @@ def record( return - _write_master(data) + while data: + n = os.write(pty_fd, data) + data = data[n:] if not pause_time: assert start_time is not None writer.write_stdin(time.time() - start_time, data) - def _signals(signal_list: Any) -> List[Tuple[Any, Any]]: - old_handlers = [] - for sig, handler in signal_list: - old_handlers.append((sig, signal.signal(sig, handler))) - return old_handlers - - def _copy(signal_fd: int) -> None: # pylint: disable=too-many-branches - """Main select loop. - - Passes control to _master_read() or _stdin_read() - when new data arrives. - """ - - fds = [master_fd, tty_stdin_fd, signal_fd] - + 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): @@ -124,78 +95,87 @@ def record( if e.errno == errno.EINTR: continue - if master_fd in rfds: - data = os.read(master_fd, 1024) + if pty_fd in rfds: + data = os.read(pty_fd, 1024) + if not data: # Reached EOF. - fds.remove(master_fd) + fds.remove(pty_fd) else: - _handle_master_read(data) + 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) + 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) + 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 [ - signal.SIGCHLD, - signal.SIGHUP, - signal.SIGTERM, - signal.SIGQUIT, - ]: - os.close(master_fd) - return - if sig == signal.SIGWINCH: - _set_pty_size() - pid, master_fd = pty.fork() + for sig in signals: + if sig in EXIT_SIGNALS: + os.close(pty_fd) + return + elif sig == signal.SIGWINCH: + set_pty_size() + + pid, pty_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() + set_pty_size() - with raw(tty_stdin_fd): - try: - _copy(pipe_r) - except (IOError, OSError): - pass - - _signals(old_handlers) + with signal_fd(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 signal_fd: + 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)) From 7a90a30ba58593003192cd7bc0d7d3cef34ca7d8 Mon Sep 17 00:00:00 2001 From: Davis Schirmer <djds@bghost.xyz> Date: Mon, 21 Feb 2022 18:30:09 -0500 Subject: [PATCH 094/121] [typing] Annotate tests --- .pre-commit-config.yaml | 1 - tests/asciicast/v2_test.py | 11 +++---- tests/config_test.py | 62 ++++++++++++++++++++------------------ tests/pty_test.py | 22 ++++++++------ tests/test_helper.py | 47 +++++------------------------ 5 files changed, 57 insertions(+), 86 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5afb297..d5cdc9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,4 +37,3 @@ repos: rev: "v0.931" hooks: - id: mypy - exclude: tests diff --git a/tests/asciicast/v2_test.py b/tests/asciicast/v2_test.py index 65f0a12..113ddf7 100644 --- a/tests/asciicast/v2_test.py +++ b/tests/asciicast/v2_test.py @@ -1,13 +1,14 @@ import json import tempfile -import asciinema.asciicast.v2 as v2 +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: @@ -16,7 +17,7 @@ class TestWriter(Test): w.write_stdout(3, bytes.fromhex("82 c4 87")) w.write_stdout(4, bytes.fromhex("78 78")) - with open(path, "r") as f: + with open(path, "rt", encoding="utf_8") as f: lines = list(map(json.loads, f.read().strip().split("\n"))) assert lines == [ {"version": 2, "width": 80, "height": 24}, @@ -24,6 +25,4 @@ class TestWriter(Test): [2, "o", "xżó"], [3, "o", "łć"], [4, "o", "xx"], - ], ( - "got:\n\n%s" % lines - ) + ], f"got:\n\n{lines}" diff --git a/tests/config_test.py b/tests/config_test.py index 7140841..7b154ff 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -1,11 +1,15 @@ 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={}): +def create_config( + content: Optional[str] = None, env: Optional[Dict[str, str]] = None +) -> Config: # avoid redefining `dir` builtin dir_ = tempfile.mkdtemp() @@ -18,12 +22,12 @@ def create_config(content=None, env={}): return cfg.Config(dir_, env) -def read_install_id(install_id_path): +def read_install_id(install_id_path: str) -> str: with open(install_id_path, "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) @@ -39,7 +43,7 @@ def test_upgrade_no_config_file(): 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() @@ -52,7 +56,7 @@ def test_upgrade_config_file_with_api_token(): assert read_install_id(config.install_id_path) == "foo-bar-baz" -def test_upgrade_config_file_with_api_token_and_more(): +def test_upgrade_config_file_with_api_token_and_more() -> None: config = create_config( "[api]\ntoken = foo-bar-baz\nurl = http://example.com" ) @@ -68,7 +72,7 @@ def test_upgrade_config_file_with_api_token_and_more(): 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() @@ -81,7 +85,7 @@ def test_upgrade_config_file_with_user_token(): assert read_install_id(config.install_id_path) == "foo-bar-baz" -def test_upgrade_config_file_with_user_token_and_more(): +def test_upgrade_config_file_with_user_token_and_more() -> None: config = create_config( "[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com" ) @@ -97,75 +101,75 @@ def test_upgrade_config_file_with_user_token_and_more(): assert read_install_id(config.install_id_path) == "foo-bar-baz" -def test_default_api_url(): +def test_default_api_url() -> None: config = create_config("") assert config.api_url == "https://asciinema.org" -def test_default_record_stdin(): +def test_default_record_stdin() -> None: config = create_config("") assert config.record_stdin is False -def test_default_record_command(): +def test_default_record_command() -> None: config = create_config("") assert config.record_command is None -def test_default_record_env(): +def test_default_record_env() -> None: config = create_config("") assert config.record_env == "SHELL,TERM" -def test_default_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(): +def test_default_record_yes() -> None: config = create_config("") assert config.record_yes is False -def test_default_record_quiet(): +def test_default_record_quiet() -> None: config = create_config("") assert config.record_quiet is False -def test_default_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 config.api_url == "http://the/url" -def test_api_url_when_override_set(): +def test_api_url_when_override_set() -> None: config = create_config( "[api]\nurl = http://the/url", {"ASCIINEMA_API_URL": "http://the/url2"} ) assert config.api_url == "http://the/url2" -def test_record_command(): +def test_record_command() -> None: command = "bash -l" - config = create_config("[record]\ncommand = %s" % command) + 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 config.record_stdin is True -def test_record_env(): +def test_record_env() -> None: config = create_config("[record]\nenv = FOO,BAR") 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 config.record_idle_time_limit == 2.35 @@ -173,19 +177,19 @@ def test_record_idle_time_limit(): assert config.record_idle_time_limit == 2.35 -def test_record_yes(): +def test_record_yes() -> None: yes = "yes" - config = create_config("[record]\nyes = %s" % yes) + config = create_config(f"[record]\nyes = {yes}") assert config.record_yes is True -def test_record_quiet(): +def test_record_quiet() -> None: quiet = "yes" - config = create_config("[record]\nquiet = %s" % quiet) + 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 config.play_idle_time_limit == 2.35 @@ -193,7 +197,7 @@ def test_play_idle_time_limit(): assert config.play_idle_time_limit == 2.35 -def test_notifications_enabled(): +def test_notifications_enabled() -> None: config = create_config("") assert config.notifications_enabled is True @@ -204,7 +208,7 @@ def test_notifications_enabled(): assert config.notifications_enabled is False -def test_notifications_command(): +def test_notifications_command() -> None: config = create_config("") assert config.notifications_command is None diff --git a/tests/pty_test.py b/tests/pty_test.py index 56cbaf8..0f309c7 100644 --- a/tests/pty_test.py +++ b/tests/pty_test.py @@ -1,5 +1,6 @@ import os import pty +from typing import Any, List, Union import asciinema.pty_ @@ -7,29 +8,30 @@ from .test_helper import Test class Writer: - def __init__(self): - self.data = [] + def __init__(self) -> None: + self.data: List[Union[float, str]] = [] - 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): + @staticmethod + def test_record_command_writes_to_stdout() -> None: writer = Writer() command = [ diff --git a/tests/test_helper.py b/tests/test_helper.py index 052fca0..03b7e97 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -1,49 +1,16 @@ import sys +from codecs import StreamReader +from io import StringIO +from typing import Optional, TextIO, Union -try: - from StringIO import StringIO -except ImportError: - from io import StringIO - - -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 {} From 9de36195fd39de94a814fa43157bd090e6081c58 Mon Sep 17 00:00:00 2001 From: Davis Schirmer <djds@bghost.xyz> Date: Mon, 21 Feb 2022 19:05:41 -0500 Subject: [PATCH 095/121] [recorder] Specify UTF-8 encoding --- asciinema/recorder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/asciinema/recorder.py b/asciinema/recorder.py index fd8d20b..0fdddac 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -78,14 +78,14 @@ class tty_fds: def __enter__(self) -> Tuple[int, int]: try: - self.stdin_file = open("/dev/tty", "r") + self.stdin_file = open("/dev/tty", "rt", encoding="utf_8") except OSError: - self.stdin_file = open("/dev/null", "r") + self.stdin_file = open("/dev/null", "rt", encoding="utf_8") try: - self.stdout_file = open("/dev/tty", "w") + self.stdout_file = open("/dev/tty", "wt", encoding="utf_8") except OSError: - self.stdout_file = open("/dev/null", "w") + self.stdout_file = open("/dev/null", "wt", encoding="utf_8") return (self.stdin_file.fileno(), self.stdout_file.fileno()) @@ -96,7 +96,7 @@ class tty_fds: self.stdout_file.close() -def build_metadata( +def build_metadata( # pylint: disable=too-many-arguments cols: int, rows: int, idle_time_limit: Optional[float], From ce5fd49821640036a8c00bc385e6b05b330c65b0 Mon Sep 17 00:00:00 2001 From: Davis Schirmer <djds@bghost.xyz> Date: Mon, 21 Feb 2022 19:09:20 -0500 Subject: [PATCH 096/121] [pty_] Avoid shadowning class name with copy() method argument --- asciinema/pty_.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 78e5cb7..4ea993a 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -128,8 +128,8 @@ def record( for sig in signals: if sig in EXIT_SIGNALS: os.close(pty_fd) - return - elif sig == signal.SIGWINCH: + return None + if sig == signal.SIGWINCH: set_pty_size() pid, pty_fd = pty.fork() @@ -140,7 +140,7 @@ def record( start_time = time.time() set_pty_size() - with signal_fd(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd: + with SignalFD(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd: with raw(tty_stdin_fd): try: copy(sig_fd) @@ -150,7 +150,7 @@ def record( os.waitpid(pid, 0) -class signal_fd: +class SignalFD: def __init__(self, signals: List[signal.Signals]) -> None: self.signals = signals self.orig_handlers: List[Tuple[signal.Signals, Any]] = [] From 1bdc4467650d2221fc4689d0c6a7ce7cd7c1429d Mon Sep 17 00:00:00 2001 From: Davis Schirmer <djds@bghost.xyz> Date: Mon, 21 Feb 2022 19:17:42 -0500 Subject: [PATCH 097/121] [style] Add pylint exceptions --- asciinema/asciicast/raw.py | 3 ++- asciinema/asciicast/v2.py | 1 + pyproject.toml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index a6978df..4e6c757 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -6,7 +6,7 @@ from ..file_writer import file_writer class writer(file_writer): - def __init__( + def __init__( # pylint: disable=too-many-arguments self, path_: str, metadata: Any = None, @@ -32,5 +32,6 @@ class writer(file_writer): def write_stdin(self, ts: float, data: Any) -> None: pass + # pylint: disable=consider-using-with def _open_file(self) -> None: self.file = open(self.path, mode=self.mode, buffering=self.buffering) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 44f291c..95d4c71 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -140,6 +140,7 @@ class writer(file_writer): data = self.stdin_decoder.decode(data) self.__write_event(ts, "i", data) + # pylint: disable=consider-using-with def _open_file(self) -> None: self.file = open( self.path, diff --git a/pyproject.toml b/pyproject.toml index 07efeb3..cd9ced2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,4 @@ disable = [ "missing-function-docstring", "missing-module-docstring", ] +min-similarity-lines = 7 From ff8927d0171b663683c65954dc78c0d557b80a5f Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 10 Apr 2022 17:04:27 +0200 Subject: [PATCH 098/121] Print all user/diagnostic message to stderr --- asciinema/commands/command.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index 8036de1..6918ee3 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -1,6 +1,6 @@ import os import sys -from typing import Any, Dict, TextIO +from typing import Any, Dict from ..api import Api from ..config import Config @@ -14,21 +14,20 @@ class Command: def print( self, text: str, - file_: TextIO = sys.stdout, end: str = "\n", force: bool = False, ) -> None: if not self.quiet or force: - print(text, file=file_, end=end) + print(text, file=sys.stderr, end=end) def print_info(self, text: str) -> None: - if os.isatty(sys.stdout.fileno()): + if os.isatty(sys.stderr.fileno()): self.print(f"\x1b[0;32masciinema: {text}\x1b[0m") else: self.print(f"asciinema: {text}") def print_warning(self, text: str) -> None: - if os.isatty(sys.stdout.fileno()): + if os.isatty(sys.stderr.fileno()): self.print(f"\x1b[0;33masciinema: {text}\x1b[0m") else: self.print(f"asciinema: {text}") @@ -37,8 +36,7 @@ class Command: if os.isatty(sys.stderr.fileno()): self.print( f"\x1b[0;31masciinema: {text}\x1b[0m", - file_=sys.stderr, force=True, ) else: - self.print(f"asciinema: {text}", file_=sys.stderr, force=True) + self.print(f"asciinema: {text}", force=True) From 2fe0584450aaacd2f9873f746e8885ef74e05a5f Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sun, 10 Apr 2022 17:05:12 +0200 Subject: [PATCH 099/121] DRY-up color handling in Command.print* methods --- asciinema/commands/command.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py index 6918ee3..5de55dd 100644 --- a/asciinema/commands/command.py +++ b/asciinema/commands/command.py @@ -1,6 +1,6 @@ import os import sys -from typing import Any, Dict +from typing import Any, Dict, Optional from ..api import Api from ..config import Config @@ -15,28 +15,20 @@ class Command: self, text: str, end: str = "\n", + color: Optional[int] = None, force: bool = False, ) -> None: if not self.quiet or force: + if color is not None and os.isatty(sys.stderr.fileno()): + text = f"\x1b[0;3{color}m{text}\x1b[0m" + print(text, file=sys.stderr, end=end) def print_info(self, text: str) -> None: - if os.isatty(sys.stderr.fileno()): - self.print(f"\x1b[0;32masciinema: {text}\x1b[0m") - else: - self.print(f"asciinema: {text}") + self.print(f"asciinema: {text}", color=2) def print_warning(self, text: str) -> None: - if os.isatty(sys.stderr.fileno()): - self.print(f"\x1b[0;33masciinema: {text}\x1b[0m") - else: - self.print(f"asciinema: {text}") + self.print(f"asciinema: {text}", color=3) def print_error(self, text: str) -> None: - if os.isatty(sys.stderr.fileno()): - self.print( - f"\x1b[0;31masciinema: {text}\x1b[0m", - force=True, - ) - else: - self.print(f"asciinema: {text}", force=True) + self.print(f"asciinema: {text}", color=1, force=True) From 17851573364824ddae8c7728ebd7913237a71643 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Tue, 26 Apr 2022 23:19:21 +0200 Subject: [PATCH 100/121] Upgrade black Solves https://github.com/psf/black/issues/2964 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5cdc9f..8d7215e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black - repo: https://github.com/adrienverge/yamllint From 80282718696eede95470f1a24e016b99043b58a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Apr 2022 21:26:38 +0000 Subject: [PATCH 101/121] Bump actions/checkout from 2 to 3 Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/asciinema.yml | 6 +++--- .github/workflows/pre-commit.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 2ca74ca..9f6c749 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout asciinema - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: setup Python uses: actions/setup-python@v2 with: @@ -42,7 +42,7 @@ jobs: TERM: dumb steps: - name: checkout Asciinema - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: setup Python uses: actions/setup-python@v2 with: @@ -102,7 +102,7 @@ jobs: options: "--interactive --tty --user=1001:121" steps: - name: checkout Asciinema - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: run integration tests env: TERM: dumb diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index e157632..dd3d51b 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -7,7 +7,7 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-python@v2 with: python-version: "3.10" From f38bf40baf3e7d48a7ac3978c92e3e4a52ed1932 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Apr 2022 21:44:21 +0000 Subject: [PATCH 102/121] Bump actions/setup-python from 2 to 3.1.1 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 3.1.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v2...v3.1.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/asciinema.yml | 4 ++-- .github/workflows/pre-commit.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 9f6c749..33bd429 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -12,7 +12,7 @@ jobs: - name: checkout asciinema uses: actions/checkout@v3 - name: setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" - name: install dependencies @@ -44,7 +44,7 @@ jobs: - name: checkout Asciinema uses: actions/checkout@v3 - name: setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: install dependencies diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index dd3d51b..5c1a29d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 with: python-version: "3.10" - uses: pre-commit/action@v2.0.3 From 780d5f8f001f0ccb4b3ef91bb92a5cde97e02fad Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Wed, 27 Apr 2022 19:40:21 +0200 Subject: [PATCH 103/121] Rename term module to tty --- asciinema/commands/cat.py | 2 +- asciinema/player.py | 2 +- asciinema/pty_.py | 2 +- asciinema/{term.py => tty_.py} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename asciinema/{term.py => tty_.py} (100%) diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py index e791548..ce5ac93 100644 --- a/asciinema/commands/cat.py +++ b/asciinema/commands/cat.py @@ -3,7 +3,7 @@ from typing import Any, Dict from .. import asciicast from ..config import Config -from ..term import raw +from ..tty_ import raw from .command import Command diff --git a/asciinema/player.py b/asciinema/player.py index f7ebe50..341f656 100644 --- a/asciinema/player.py +++ b/asciinema/player.py @@ -5,7 +5,7 @@ from typing import Any, Dict, Optional, TextIO, Union from .asciicast import events as ev from .asciicast.v1 import Asciicast as v1 from .asciicast.v2 import Asciicast as v2 -from .term import raw, read_blocking +from .tty_ import raw, read_blocking class Player: # pylint: disable=too-few-public-methods diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 4ea993a..29d6add 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -10,7 +10,7 @@ import termios import time from typing import Any, Callable, Dict, List, Optional, Tuple -from .term import raw +from .tty_ import raw EXIT_SIGNALS = [ signal.SIGHUP, diff --git a/asciinema/term.py b/asciinema/tty_.py similarity index 100% rename from asciinema/term.py rename to asciinema/tty_.py From 20e04f73008ef711bb5c694361b7d7a3a65e0a58 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Wed, 27 Apr 2022 22:31:37 +0200 Subject: [PATCH 104/121] Dummy default for on_error in file_writer --- asciinema/file_writer.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py index 463e98c..7433cad 100644 --- a/asciinema/file_writer.py +++ b/asciinema/file_writer.py @@ -11,7 +11,7 @@ class file_writer: ) -> None: self.path = path self.file: Optional[IO[Any]] = None - self.on_error = on_error + self.on_error = on_error or (lambda _x: None) def __enter__(self) -> Any: self._open_file() @@ -31,13 +31,10 @@ class file_writer: self.file.write(data) # type: ignore except BrokenPipeError as e: if stat.S_ISFIFO(os.stat(self.path).st_mode): - if self.on_error: - self.on_error("Broken pipe, reopening...") - self._open_file() - self.on_error("Output pipe reopened successfully") - else: - self._open_file() - + 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 From e481015934e1d6f9992569b58ae36bccb4173285 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Wed, 27 Apr 2022 22:33:50 +0200 Subject: [PATCH 105/121] Treat dash char (-) as stdout for output filename --- asciinema/asciicast/raw.py | 13 ++++++++++++- asciinema/asciicast/v2.py | 23 +++++++++++++++++------ asciinema/commands/record.py | 6 +++++- asciinema/file_writer.py | 2 +- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py index 4e6c757..b52441f 100644 --- a/asciinema/asciicast/raw.py +++ b/asciinema/asciicast/raw.py @@ -1,4 +1,5 @@ import os +import sys from os import path from typing import Any, Callable, Optional @@ -34,4 +35,14 @@ class writer(file_writer): # pylint: disable=consider-using-with def _open_file(self) -> None: - self.file = open(self.path, mode=self.mode, buffering=self.buffering) + 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 + ) diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py index 95d4c71..c9dcb8f 100644 --- a/asciinema/asciicast/v2.py +++ b/asciinema/asciicast/v2.py @@ -1,5 +1,7 @@ import codecs import json +import os +import sys from codecs import StreamReader from json.decoder import JSONDecodeError from typing import ( @@ -142,12 +144,21 @@ class writer(file_writer): # pylint: disable=consider-using-with def _open_file(self) -> None: - self.file = open( - self.path, - mode=self.mode, - buffering=self.buffering, - encoding="utf-8", - ) + 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]) diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py index 81af8ea..d60cdf5 100644 --- a/asciinema/commands/record.py +++ b/asciinema/commands/record.py @@ -52,7 +52,10 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes 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(f"can't write to {self.filename}") return 1 @@ -72,6 +75,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes "if you want to append to existing recording" ) return 1 + elif append: self.print_warning( f"{self.filename} does not exist, not appending" diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py index 7433cad..13cd27b 100644 --- a/asciinema/file_writer.py +++ b/asciinema/file_writer.py @@ -30,7 +30,7 @@ class file_writer: try: self.file.write(data) # type: ignore except BrokenPipeError as e: - if stat.S_ISFIFO(os.stat(self.path).st_mode): + 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") From 852b1f5c24fd3e7db921eeed825a0147ab71e5f4 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <17589+sickill@users.noreply.github.com> Date: Mon, 2 May 2022 21:45:43 +0200 Subject: [PATCH 106/121] Correct the version of the recorder that added v2 format support --- doc/asciicast-v2.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/asciicast-v2.md b/doc/asciicast-v2.md index b45ddc1..ff7dddb 100644 --- a/doc/asciicast-v2.md +++ b/doc/asciicast-v2.md @@ -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 From 32b6cfa6461785fe8070e8afaa4922a30679201b Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Mon, 2 May 2022 23:05:02 +0200 Subject: [PATCH 107/121] Catch SIGCHLD - needed on macOS --- asciinema/pty_.py | 1 + 1 file changed, 1 insertion(+) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index 29d6add..c9377e8 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -13,6 +13,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from .tty_ import raw EXIT_SIGNALS = [ + signal.SIGCHLD, signal.SIGHUP, signal.SIGTERM, signal.SIGQUIT, From fb35f0bf8603012356fdcadf6a87858d365280c3 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Mon, 2 May 2022 23:08:08 +0200 Subject: [PATCH 108/121] Fix pickling issue in file_writer on macOS --- asciinema/file_writer.py | 6 +++++- asciinema/recorder.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py index 13cd27b..c07e2d9 100644 --- a/asciinema/file_writer.py +++ b/asciinema/file_writer.py @@ -11,7 +11,7 @@ class file_writer: ) -> None: self.path = path self.file: Optional[IO[Any]] = None - self.on_error = on_error or (lambda _x: None) + self.on_error = on_error or noop def __enter__(self) -> Any: self._open_file() @@ -38,3 +38,7 @@ class file_writer: else: self.on_error("Output pipe broken") raise e + + +def noop(_: Any) -> None: + return None diff --git a/asciinema/recorder.py b/asciinema/recorder.py index 0fdddac..14f27e5 100644 --- a/asciinema/recorder.py +++ b/asciinema/recorder.py @@ -55,7 +55,7 @@ def record( # pylint: disable=too-many-arguments,too-many-locals ) sync_writer = writer( - path_, metadata, append, on_error=_notifier.notify + path_, metadata, append, on_error=_notifier.queue.put ) with async_writer(sync_writer, time_offset, record_stdin) as _writer: From eae8b2dafd5971e623423dcb004391f0cf15e83e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 May 2022 08:35:26 +0000 Subject: [PATCH 109/121] Bump docker/login-action from 1 to 2 Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v1...v2) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/asciinema.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 33bd429..af83f69 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -68,7 +68,7 @@ jobs: id: buildx uses: docker/setup-buildx-action@v1 - name: Authenticate to GHCR - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: "${{ github.actor }}" From 695bb857e36db23380f94746a8f0db5848a06626 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 May 2022 08:35:30 +0000 Subject: [PATCH 110/121] Bump docker/build-push-action from 2 to 3 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2 to 3. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/asciinema.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index 33bd429..81f74ea 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -74,7 +74,7 @@ jobs: username: "${{ github.actor }}" password: "${{ secrets.GITHUB_TOKEN }}" - name: "Build ${{ matrix.distros }} image" - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: file: "tests/distros/Dockerfile.${{ matrix.distros }}" tags: | From 092879e979125210c208e41a179ac3cf33f43412 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 May 2022 09:12:31 +0000 Subject: [PATCH 111/121] Bump docker/setup-buildx-action from 1 to 2 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/asciinema.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml index aed4b7e..7a3200e 100644 --- a/.github/workflows/asciinema.yml +++ b/.github/workflows/asciinema.yml @@ -66,7 +66,7 @@ jobs: steps: - name: Set up Docker buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Authenticate to GHCR uses: docker/login-action@v2 with: From d31f1e9d8edf1a1a6ee88e1e6d6fb6865ecf9865 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Thu, 5 May 2022 22:53:05 +0200 Subject: [PATCH 112/121] Add changelog entry for v2.1.0 --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f686650..a2efd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # asciinema changelog +## 2.2.0 (unreleased) + +## 2.1.0 (2021-10-02) + +* Ability to pause/unpause recording with ctrl+p +* Desktop notifications (for pause/unpause) +* Configurable hotkeys for recording and playback +* Fixed ASCIINEMA_REC env var setting for recording session +* Fixed codeset detection on HP-UX +* Fixed encoding of basic auth header for upload to asciinema server +* `--overwrite` option is now suggested when dest file exists +* Terminal answerbacks are now captured by `asciinema cat` +* Internal refactorings + ## 2.0.2 (2019-01-12) * Official support for Python 3.7 From e4e7f8f3208d092a4069cb95953fca3555a4ecc6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Thu, 5 May 2022 22:59:05 +0200 Subject: [PATCH 113/121] Fix pause keyboard shortcut in docs --- CHANGELOG.md | 2 +- README.md | 4 ++-- man/asciinema.1.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2efd8b..076b734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## 2.1.0 (2021-10-02) -* Ability to pause/unpause recording with ctrl+p +* Ability to pause/unpause recording session with ctrl+\ * Desktop notifications (for pause/unpause) * Configurable hotkeys for recording and playback * Fixed ASCIINEMA_REC env var setting for recording session diff --git a/README.md b/README.md index d333749..b1dc280 100644 --- a/README.md +++ b/README.md @@ -194,10 +194,10 @@ By running `asciinema rec [filename]` you start a new recording session. The command (process) that is recorded can be specified with `-c` option (see below), and defaults to `$SHELL` which is what you want in most cases. -You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. +You can temporarily pause recording of terminal by pressing <kbd>Ctrl+\</kbd>. This is useful when you want to execute some commands during the recording session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+P</kbd> again. +<kbd>Ctrl+\</kbd> again. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when diff --git a/man/asciinema.1.md b/man/asciinema.1.md index 39ad08c..59f162f 100644 --- a/man/asciinema.1.md +++ b/man/asciinema.1.md @@ -42,10 +42,10 @@ By running **asciinema rec [filename]** you start a new recording session. The command (process) that is recorded can be specified with **-c** option (see below), and defaults to **$SHELL** which is what you want in most cases. -You can temporarily pause recording of terminal by pressing <kbd>Ctrl+P</kbd>. +You can temporarily pause recording of terminal by pressing <kbd>Ctrl+\\</kbd>. This is useful when you want to execute some commands during the recording session that should not be captured (e.g. pasting secrets). Resume by pressing -<kbd>Ctrl+P</kbd> again. +<kbd>Ctrl+\\</kbd> again. Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type `exit`). If the recorded process is not a shell then recording finishes when From 478a7f47a58a2102703399ba5ae4fc6a45e940ac Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 11:16:09 +0200 Subject: [PATCH 114/121] Regenerate manpage for 2.1.0 --- man/asciinema.1 | 357 +++++++++++++++++++++++++++++------------------- 1 file changed, 218 insertions(+), 139 deletions(-) diff --git a/man/asciinema.1 b/man/asciinema.1 index f3c1c60..6d87af8 100644 --- a/man/asciinema.1 +++ b/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 -.RE +\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 -.B \f[C]\-s,\ \-\-speed=<factor>\f[] +\f[V]-s, --speed=<factor>\f[R] Playback speed (can be fractional) +.RE +.TP +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> From 132012270eeb85bc7470d695312fd1a658f03e72 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 11:26:05 +0200 Subject: [PATCH 115/121] Remove trailing whitespace from man page --- man/asciinema.1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/man/asciinema.1 b/man/asciinema.1 index 6d87af8..6afa4c6 100644 --- a/man/asciinema.1 +++ b/man/asciinema.1 @@ -77,7 +77,7 @@ This can be used by your shell\[cq]s config file (\f[V].bashrc\f[R], being recorded. .TP Available options: -\ +\ .RS .TP \f[V]--stdin\f[R] @@ -181,7 +181,7 @@ asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.c .fi .TP Available options: -\ +\ .RS .TP \f[V]-i, --idle-time-limit=<sec>\f[R] @@ -193,7 +193,7 @@ Playback speed (can be fractional) .RE .TP While playing the following keyboard shortcuts are available: -\ +\ .RS .TP \f[I]\f[VI]Space\f[I]\f[R] @@ -353,14 +353,14 @@ asciinema rec -c \[dq]asciinema play -s 5 -i 2 initial.cast\[dq] -t \[dq]My fanc .RE .TP 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 - From 2eb84d257b90e83f9612643ed47bf5c9aeddf543 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 14:34:03 +0200 Subject: [PATCH 116/121] Update changelog for 2.2 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 076b734..8fa61d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## 2.2.0 (unreleased) +* 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 +* 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/unpause recording session with ctrl+\ From badc93bfb80f6498a0e188e060e608bf0b40feff Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 15:58:04 +0200 Subject: [PATCH 117/121] Re-enable integration test for recording program that exits immediately --- tests/integration.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration.sh b/tests/integration.sh index c4554df..0c36939 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -68,9 +68,8 @@ asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cas 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 who "${TMP_DATA_DIR}/1b.cast" +grep '"o",' "${TMP_DATA_DIR}/1b.cast" # signal handling bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" & From 607bcb4bb065a787393abd223c780a52bd14fbf0 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 16:26:21 +0200 Subject: [PATCH 118/121] Try integration test for stdin recording with whoami instead of who --- tests/integration.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration.sh b/tests/integration.sh index 0c36939..5053d87 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -68,7 +68,7 @@ asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cas grep '"o",' "${TMP_DATA_DIR}/1a.cast" # very quickly exiting program -asciinema rec -c who "${TMP_DATA_DIR}/1b.cast" +asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast" grep '"o",' "${TMP_DATA_DIR}/1b.cast" # signal handling From 0ba83391efe97cf9bd6bc8d6f7ffcf8cb602cee7 Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 16:16:53 +0200 Subject: [PATCH 119/121] Fix stdin recording (broken during pty refactor) --- asciinema/pty_.py | 7 ++++--- tests/integration.sh | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/asciinema/pty_.py b/asciinema/pty_.py index c9377e8..2c62717 100644 --- a/asciinema/pty_.py +++ b/asciinema/pty_.py @@ -74,9 +74,10 @@ def record( return - while data: - n = os.write(pty_fd, data) - data = data[n:] + 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 diff --git a/tests/integration.sh b/tests/integration.sh index 5053d87..9f4f5d1 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -82,7 +82,10 @@ 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" # 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" From 20fba124dbe6d92bd205395c02a95fb6caf3992e Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 20:19:28 +0200 Subject: [PATCH 120/121] Words --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa61d0..56a984b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * 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 +* 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 From 76303b29510d2c3169730930018bf40069d7f9bd Mon Sep 17 00:00:00 2001 From: Marcin Kulik <m@ku1ik.com> Date: Sat, 7 May 2022 20:24:57 +0200 Subject: [PATCH 121/121] Set release date for 2.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a984b..723a975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # asciinema changelog -## 2.2.0 (unreleased) +## 2.2.0 (2022-05-07) * Added official support for Python 3.8, 3.9, 3.10 * Dropped official support for Python 3.5