summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 16:27:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 16:27:43 +0000
commit5a5427c3888e2619d53f6943447b7db5bf082366 (patch)
tree3ecafb5ca77f89abdeddb14eb42f90187b811e0d
parentInitial commit. (diff)
downloadasciinema-5a5427c3888e2619d53f6943447b7db5bf082366.tar.xz
asciinema-5a5427c3888e2619d53f6943447b7db5bf082366.zip
Adding upstream version 2.2.0.upstream/2.2.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.dockerignore2
-rw-r--r--.github/dependabot.yml7
-rw-r--r--.github/workflows/asciinema.yml110
-rw-r--r--.github/workflows/pre-commit.yml14
-rw-r--r--.gitignore126
-rw-r--r--.pre-commit-config.yaml39
-rw-r--r--CHANGELOG.md207
-rw-r--r--CODE_OF_CONDUCT.md74
-rw-r--r--CONTRIBUTING.md92
-rw-r--r--Dockerfile38
-rw-r--r--LICENSE674
-rw-r--r--Makefile75
-rw-r--r--README.md473
-rw-r--r--Vagrantfile21
-rw-r--r--asciinema/__init__.py34
-rw-r--r--asciinema/__main__.py223
-rw-r--r--asciinema/api.py101
-rw-r--r--asciinema/asciicast/__init__.py134
-rw-r--r--asciinema/asciicast/events.py41
-rw-r--r--asciinema/asciicast/raw.py48
-rw-r--r--asciinema/asciicast/v1.py62
-rw-r--r--asciinema/asciicast/v2.py171
-rw-r--r--asciinema/async_worker.py46
-rw-r--r--asciinema/commands/__init__.py0
-rw-r--r--asciinema/commands/auth.py20
-rw-r--r--asciinema/commands/cat.py28
-rw-r--r--asciinema/commands/command.py34
-rw-r--r--asciinema/commands/play.py40
-rw-r--r--asciinema/commands/record.py165
-rw-r--r--asciinema/commands/upload.py33
-rw-r--r--asciinema/config.py220
-rw-r--r--asciinema/data/icon-256x256.pngbin0 -> 25280 bytes
-rw-r--r--asciinema/file_writer.py44
-rw-r--r--asciinema/http_adapter.py2
-rw-r--r--asciinema/notifier.py121
-rw-r--r--asciinema/player.py96
-rw-r--r--asciinema/pty_.py183
-rw-r--r--asciinema/py.typed0
-rw-r--r--asciinema/recorder.py191
-rw-r--r--asciinema/tty_.py34
-rw-r--r--asciinema/urllib_http_adapter.py124
-rw-r--r--doc/asciicast-v1.md64
-rw-r--r--doc/asciicast-v2.md183
-rw-r--r--man/Makefile4
-rw-r--r--man/asciinema.1403
-rw-r--r--man/asciinema.1.md348
-rw-r--r--pyproject.toml38
-rw-r--r--setup.cfg59
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/asciicast/__init__.py0
-rw-r--r--tests/asciicast/v2_test.py28
-rw-r--r--tests/config_test.py218
-rw-r--r--tests/demo.cast40
-rw-r--r--tests/demo.json114
-rwxr-xr-xtests/distros.sh38
-rw-r--r--tests/distros/Dockerfile.alpine19
-rw-r--r--tests/distros/Dockerfile.arch22
-rw-r--r--tests/distros/Dockerfile.centos18
-rw-r--r--tests/distros/Dockerfile.debian33
-rw-r--r--tests/distros/Dockerfile.fedora20
-rw-r--r--tests/distros/Dockerfile.ubuntu32
-rwxr-xr-xtests/integration.sh95
-rw-r--r--tests/pty_test.py54
-rw-r--r--tests/test_helper.py16
64 files changed, 5993 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b2fcb9e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+dist/*
+**/*.pyc
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"
diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml
new file mode 100644
index 0000000..7a3200e
--- /dev/null
+++ b/.github/workflows/asciinema.yml
@@ -0,0 +1,110 @@
+---
+name: build
+on:
+ - push
+ - pull_request
+jobs:
+ # Code style checks
+ health:
+ name: code health check
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout asciinema
+ uses: actions/checkout@v3
+ - name: setup Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: "3.10"
+ - name: install dependencies
+ run: pip install build cmarkgfm pycodestyle twine
+ - name: Run pycodestyle
+ run: >
+ find .
+ -name '*\.py'
+ -exec pycodestyle --ignore=E402,E501,E722,W503 "{}" \+
+ - name: Run twine
+ run: |
+ python3 -m build
+ twine check dist/*
+ # Asciinema checks
+ asciinema:
+ name: Asciinema
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python:
+ - "3.6"
+ - "3.7"
+ - "3.8"
+ - "3.9"
+ - "3.10"
+ env:
+ TERM: dumb
+ steps:
+ - name: checkout Asciinema
+ uses: actions/checkout@v3
+ - name: setup Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: ${{ matrix.python }}
+ - name: install dependencies
+ run: pip install pytest
+ - name: run Asciinema tests
+ run: script -e -c make test
+ build_distros:
+ name: build distro images
+ strategy:
+ matrix:
+ distros:
+ - alpine
+ - arch
+ - centos
+ - debian
+ - fedora
+ - ubuntu
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Docker buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v2
+ - name: Authenticate to GHCR
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: "${{ github.actor }}"
+ password: "${{ secrets.GITHUB_TOKEN }}"
+ - name: "Build ${{ matrix.distros }} image"
+ uses: docker/build-push-action@v3
+ with:
+ file: "tests/distros/Dockerfile.${{ matrix.distros }}"
+ tags: |
+ "ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
+ push: true
+ test_distros:
+ name: integration test distro images
+ needs: build_distros
+ strategy:
+ matrix:
+ distros:
+ - alpine
+ - arch
+ - centos
+ - debian
+ - fedora
+ - ubuntu
+ runs-on: ubuntu-latest
+ container:
+ image: "ghcr.io/${{ github.repository }}:${{ matrix.distros }}"
+ credentials:
+ username: "${{ github.actor }}"
+ password: "${{ secrets.GITHUB_TOKEN }}"
+ # https://github.community/t/permission-problems-when-checking-out-code-as-part-of-github-action/202263
+ options: "--interactive --tty --user=1001:121"
+ steps:
+ - name: checkout Asciinema
+ uses: actions/checkout@v3
+ - name: run integration tests
+ env:
+ TERM: dumb
+ shell: 'script --return --quiet --command "bash {0}"'
+ run: make test.integration
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..5c1a29d
--- /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@v3
+ - uses: actions/setup-python@v3
+ with:
+ python-version: "3.10"
+ - uses: pre-commit/action@v2.0.3
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b5e6374
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,126 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# custom
+/dist
+tmp
+*.pyc
+*.tar.gz
+*.tar.bz2
+*.tar.xz
+*.zip
+*.egg-info
+/build
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..8d7215e
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,39 @@
+---
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.1.0
+ hooks:
+ - id: check-ast
+ - id: check-json
+ - id: check-toml
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: requirements-txt-fixer
+ - id: trailing-whitespace
+ - repo: https://github.com/PyCQA/isort
+ rev: 5.10.1
+ hooks:
+ - id: isort
+ - repo: https://github.com/psf/black
+ rev: 22.3.0
+ hooks:
+ - id: black
+ - repo: https://github.com/adrienverge/yamllint
+ rev: v1.26.3
+ hooks:
+ - id: yamllint
+ - repo: https://github.com/myint/autoflake
+ rev: v1.4
+ hooks:
+ - id: autoflake
+ args:
+ - --in-place
+ - --recursive
+ - --expand-star-imports
+ - --remove-all-unused-imports
+ - --remove-duplicate-keys
+ - --remove-unused-variables
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v0.931"
+ hooks:
+ - id: mypy
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b7013af
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,207 @@
+# asciinema changelog
+
+## 2.2.0 (2022-05-07)
+
+* Added official support for Python 3.8, 3.9, 3.10
+* Dropped official support for Python 3.5
+* Added `--cols` / `--rows` options for overriding size of pseudo-terminal reported to recorded program
+* Improved behaviour of `--append` when output file doesn't exist
+* Keyboard input is now explicitly read from a TTY device in addition to stdin (when stdin != TTY)
+* Recorded program output is now explicitly written to a TTY device instead of stdout
+* Dash char (`-`) can now be passed as output filename to write asciicast to stdout
+* Diagnostic messages are now printed to stderr (without colors when stderr != TTY)
+* Improved robustness of writing asciicast to named pipes
+* Lots of codebase modernizations (many thanks to Davis @djds Schirmer!)
+* Many other internal refactorings
+
+## 2.1.0 (2021-10-02)
+
+* Ability to pause/resume terminal capture with `C-\` key shortcut
+* Desktop notifications - only for the above pause feature at the moment
+* Removed dependency on tput/ncurses (thanks @arp242 / Martin Tournoij!)
+* ASCIINEMA_REC env var is back (thanks @landonb / Landon Bouma!)
+* Terminal answerbacks (CSI 6 n) in `asciinema cat` are now hidden (thanks @djpohly / Devin J. Pohly!)
+* Codeset detection works on HP-UX now (thanks @michael-o / Michael Osipov!)
+* Attempt at recording to existing file suggests use of `--overwrite` option now
+* Upload for users with very long `$USER` is fixed
+* Added official support for Python 3.8 and 3.9
+* Dropped official support for EOL-ed Python 3.4 and 3.5
+
+## 2.0.2 (2019-01-12)
+
+* Official support for Python 3.7
+* Recording is now possible on US-ASCII locale (thanks Jean-Philippe @jpouellet Ouellet!)
+* Improved Android support (thanks Fredrik @fornwall Fornwall!)
+* Possibility of programatic recording with `asciinema.record_asciicast` function
+* Uses new JSON response format added recently to asciinema-server
+* Tweaked message about how to stop recording (thanks Bachynin @vanyakosmos Ivan!)
+* Added proper description and other metadata to Python package (thanks @Crestwave!)
+
+## 2.0.1 (2018-04-04)
+
+* Fixed example in asciicast v2 format doc (thanks Josh "@anowlcalledjosh" Holland!)
+* Replaced deprecated `encodestring` (since Python 3.1) with `encodebytes` (thanks @delirious-lettuce!)
+* Fixed location of config dir (you can `mv ~/.asciinema ~/.config/asciinema`)
+* Internal refactorings
+
+## 2.0 (2018-02-10)
+
+This major release brings many new features, improvements and bugfixes. The most
+notable ones:
+
+* new [asciicast v2 file format](doc/asciicast-v2.md)
+* recording and playback of arbitrarily long session with minimal memory usage
+* ability to live-stream via UNIX pipe: `asciinema rec unix.pipe` + `asciinema play unix.pipe` in second terminal tab/window
+* optional stdin recording (`asciinema rec --stdin`)
+* appending to existing recording (`asciinema rec --append <filename>`)
+* raw recording mode, storing only stdout bytes (`asciinema rec --raw <filename>`)
+* environment variable white-listing (`asciinema rec --env="VAR1,VAR2..."`)
+* toggling pause in `asciinema play` by <kbd>Space</kbd>
+* stepping through a recording one frame at a time with <kbd>.</kbd> (when playback paused)
+* new `asciinema cat <filename>` command to dump full output of the recording
+* playback from new IPFS URL scheme: `dweb:/ipfs/` (replaces `fs:/`)
+* lots of other bugfixes and improvements
+* dropped official support for Python 3.3 (although it still works on 3.3)
+
+## 1.4.0 (2017-04-11)
+
+* Dropped distutils fallback in setup.py - setuptools required now (thanks Jakub "@jakubjedelsky" Jedelsky!)
+* Dropped official support for Python 3.2 (although it still works on 3.2)
+* New `--speed` option for `asciinema play` (thanks Bastiaan "@bastiaanb" Bakker!)
+* Ability to set API token via `ASCIINEMA_API_TOKEN` env variable (thanks Samantha "@samdmarshall" Marshall!)
+* Improved shutdown on more signals: CHLD, HUP, TERM, QUIT (thanks Richard "@typerlc"!)
+* Fixed stdin handling during playback via `asciinema play`
+
+## 1.3.0 (2016-07-13)
+
+This release brings back the original Python implementation of asciinema. It's
+based on 0.9.8 codebase and adds all features and bug fixes that have been
+implemented in asciinema's Go version between 0.9.8 and 1.2.0.
+
+Other notable changes:
+
+* Zero dependencies! (other than Python 3)
+* Fixed crash when resizing terminal window during recording (#167)
+* Fixed upload from IPv6 hosts (#94)
+* Improved UTF-8 charset detection (#160)
+* `-q/--quiet` option can be saved in config file now
+* Final "logout" (produced by csh) is now removed from recorded stdout
+* `rec` command now tries to write to target path before starting recording
+
+## 1.2.0 (2016-02-22)
+
+* Added playback from stdin: `cat demo.json | asciinema play -`
+* Added playback from IPFS: `asciinema play ipfs:/ipfs/QmcdXYJp6e4zNuimuGeWPwNMHQdxuqWmKx7NhZofQ1nw2V`
+* Added playback from asciicast page URL: `asciinema play https://asciinema.org/a/22124`
+* `-q/--quiet` option added to `rec` command
+* Fixed handling of partial UTF-8 sequences in recorded stdout
+* Final "exit" is now removed from recorded stdout
+* Longer operations like uploading/downloading show "spinner"
+
+## 1.1.1 (2015-06-21)
+
+* Fixed putting terminal in raw mode (fixes ctrl-o in nano)
+
+## 1.1.0 (2015-05-25)
+
+* `--max-wait` option is now also available for `play` command
+* Added support for compilation on FreeBSD
+* Improved locale/charset detection
+* Improved upload error messages
+* New config file location (with backwards compatibility)
+
+## 1.0.0 (2015-03-12)
+
+* `--max-wait` and `--yes` options can be saved in config file
+* Support for displaying warning messages returned from API
+* Also, see changes for 1.0.0 release candidates below
+
+## 1.0.0.rc2 (2015-03-08)
+
+* All dependencies are vendored now in Godeps dir
+* Help message includes all commands with their possible options
+* `-y` and `-t` options have longer alternatives: `--yes`, `--title`
+* `--max-wait` option has shorter alternative: `-w`
+* Import paths changed to `github.com/asciinema/asciinema` due to repository
+ renaming
+* `-y` also suppresess "please resize terminal" prompt
+
+## 1.0.0.rc1 (2015-03-02)
+
+* New [asciicast file format](doc/asciicast-v1.md)
+* `rec` command can now record to file
+* New commands: `play <filename>` and `upload <filename>`
+* UTF-8 native locale is now required
+* Added handling of status 413 and 422 by printing user friendly message
+
+## 0.9.9 (2014-12-17)
+
+* Rewritten in Go
+* License changed to GPLv3
+* `--max-wait` option added to `rec` command
+* Recorded process has `ASCIINEMA_REC` env variable set (useful for "rec"
+ indicator in shell's `$PROMPT/$RPROMPT`)
+* No more terminal resetting (via `reset` command) before and after recording
+* Informative messages are coloured to be distinguishable from normal output
+* Improved error messages
+
+## 0.9.8 (2014-02-09)
+
+* Rename user_token to api_token
+* Improvements to test suite
+* Send User-Agent including client version number, python version and platform
+* Handle 503 status as server maintenance
+* Handle 404 response as a request for client upgrade
+
+## 0.9.7 (2013-10-07)
+
+* Depend on requests==1.1.0, not 2.0
+
+## 0.9.6 (2013-10-06)
+
+* Remove install script
+* Introduce proper python package: https://pypi.python.org/pypi/asciinema
+* Make the code compatible with both python 2 and 3
+* Use requests lib instead of urrlib(2)
+
+## 0.9.5 (2013-10-04)
+
+* Fixed measurement of total recording time
+* Improvements to install script
+* Introduction of Homebrew formula
+
+## 0.9.4 (2013-10-03)
+
+* Use python2.7 in shebang
+
+## 0.9.3 (2013-10-03)
+
+* Re-enable resetting of a terminal before and after recording
+* Add Arch Linux source package
+
+## 0.9.2 (2013-10-02)
+
+* Use os.uname over running the uname command
+* Add basic integration tests
+* Make PtyRecorder test stable again
+* Move install script out of bin dir
+
+## 0.9.1 (2013-10-01)
+
+* Split monolithic script into separate classes/files
+* Remove upload queue
+* Use python2 in generated binary's shebang
+* Delay config file creation until user_token is requested
+* Introduce command classes for handling cli commands
+* Split the recorder into classes with well defined responsibilities
+* Drop curl dependency, use urllib(2) for http requests
+
+## 0.9.0 (2013-09-24)
+
+* Project rename from "ascii.io" to "asciinema"
+
+## ... limbo? ...
+
+## 0.1 (2012-03-11)
+
+* Initial release
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..1e7d394
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,74 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at support@asciinema.org. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..935ee78
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,92 @@
+# Contributing to asciinema
+
+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
+
+## Reporting bugs
+
+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.
+
+## Submitting patches with bug fixes
+
+If you found a bug and made a patch for it:
+
+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.
+
+## 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.
+
+## 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>.
+
+## 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.
+
+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
+
+mQENBFRH/yQBCADwC8fadhrTTqCFEcQ8ex82FE24b2frRC3fvkFeKsY+v2lniYmZ
+wJ+qsd3cEv5uctCl+lQjrqhJrBx5DnZpCMw85vNuOhz/wjzn7efTISUF+HlnhiZd
+tN3FPbk4uu+1JiiZ7SEvH+I4JjM46Vx6wPZ9en79u8VPMLJ24F81Rar62oiMuL29
+PGV7CdG+ErUHEQfN1qLaZNQqkPCQSAouxooNqXKjs/mmz2651FrP8TKVr2f6B/2O
+YJ++H9SoIp7Ly+/fEjgmdaZnGqfxnBC+Pm82tZguprWeh8pdiu9ieJswr4S9tRms
+h2+eht8PWwkaOOhcFdZLnJFoXHOPzHilQVutABEBAAG0KUFzY2lpbmVtYSBTdXBw
+b3J0IDxzdXBwb3J0QGFzY2lpbmVtYS5vcmc+iQE4BBMBAgAiBQJUR/8kAhsDBgsJ
+CAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRAeszqHYN7DSyCeCADS9Jk7Ibl2f+2K
+eZ4XmYU0UxU55EtHZBd34yF+FGbl4doQhnKcRqT5lKLfYk4x3LzzPAHNSbRS05/K
+fw8l72GLHY01U/3slAixphIR8LwVyqPxwelTqLzkDvcK1TTTFnOM/XUT1ymNUS7i
+6Bs889I4I8bPrnt1XK+W35/SqZbBAWotdidCbI/oKQgffCbVsH/Im5pnXTapvf/l
+sRUpB2fp7vD5+ycKDcB5CqbtnsPU9vCPL11GG3ijwQBgnPc0fKanUHb3IMElQ0ju
+8IYTZjpPe7bIV3V3nYZvdO41IYLCHhRpvNt4BO2amQoGyqTqGHr/rCY1aEToDG2c
+cOdsEOmuuQENBFRH/yQBCACsR59NPSwGoK4zGgzDjuY7yLab2Tq1Jg1c038lA23G
+t3H9aOpVbeYGvDPYLHi2y1cCNv19nzs5/k/LAflhTcgPjipTHQ2ojDG+MNfO4qyH
+3JFhm1WUw6zxFjBXfsZhoCKTNHZkzH+d0jeutbBq/Rd77sLjN/VVTLfzJCZhyhKD
+VEyO6DYaANZn1B/xx84WdxqqiQsLELOCQVUCG7HzbQAmx7lYYIUAwUoFTrBeBd+d
+sN7htw3j7le99EiccqMXceZd2W9cAlRfXcjHtvbtkbJTcsvANSUSU10q5uuT3f6l
+NftTLWOGZnu/rFU/ow5ipKft0ygfJKpMHD+AoLkiRIajABEBAAGJAR8EGAECAAkF
+AlRH/yQCGwwACgkQHrM6h2Dew0tG1wgAqOkkSznwF+6muK88GgrgasqnIq2t2VkN
+fTEKmykgSuMxiN4bsNLc4FQECZqIcL7zGuD6fFnsnO6Hg36R4rYGFSEsjjN7rXj0
+QLnrJJLZV0oA6Q77fUqdB0he7uJm+nlQjUv8HNJwp1oIyhhHz/r1kTHUlX+bEMO3
+Khc96UnE7nzwPBCbUvKuHJQY6K2ms1wgr9ELXjF1KVU9QtBtG2/XWRGDHDwQKxnW
++2pRVtn2xNJ9rBipGG86ZU88vurYjgPZrXaex3M1QGD/8+9Wlp/TR7YUzjiZbtwc
+6mpG4SUlwZheX9RbTRdjnLr7Qy+CddOWvGxebgk23/U90KrDyHDHig==
+=2M/2
+-----END PGP PUBLIC KEY BLOCK-----
+```
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..43eb960
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/ubuntu:20.04
+
+ENV DEBIAN_FRONTEND="noninteractive"
+
+RUN apt-get update \
+ && apt-get install -y \
+ ca-certificates \
+ locales \
+ python3 \
+ python3-pip \
+ && localedef \
+ -i en_US \
+ -c \
+ -f UTF-8 \
+ -A /usr/share/locale/locale.alias \
+ en_US.UTF-8
+
+COPY pyproject.toml setup.cfg *.md /usr/src/app/
+COPY doc/*.md /usr/src/app/doc/
+COPY man/asciinema.1 /usr/src/app/man/
+COPY asciinema/ /usr/src/app/asciinema/
+COPY README.md LICENSE /usr/src/app/
+
+WORKDIR /usr/src/app
+
+RUN pip3 install .
+
+WORKDIR /root
+
+ENV LANG="en_US.utf8"
+ENV SHELL="/bin/bash"
+
+ENTRYPOINT ["/usr/local/bin/asciinema"]
+CMD ["--help"]
+
+# vim:ft=dockerfile
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2700bf7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,75 @@
+NAME := asciinema
+VERSION := $(shell python3 -c "import asciinema; print(asciinema.__version__)")
+
+VIRTUAL_ENV ?= .venv
+
+.PHONY: test
+test: test.unit test.integration
+
+.PHONY: test.unit
+test.unit:
+ pytest
+
+.PHONY: test.integration
+test.integration:
+ tests/integration.sh
+
+.PHONY: test.distros
+test.distros:
+ tests/distros.sh
+
+.PHONY: release
+release: test tag push
+
+.PHONY: release.test
+release.test: test push.test
+
+.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)
+
+.PHONY: .venv
+.venv:
+ python3 -m venv $(VIRTUAL_ENV)
+
+.PHONY: .pip
+.pip: .venv
+ . $(VIRTUAL_ENV)/bin/activate \
+ && python3 -m pip install --upgrade build twine
+
+build: .pip
+ . $(VIRTUAL_ENV)/bin/activate \
+ && python3 -m build .
+
+install: build
+ . $(VIRTUAL_ENV)/bin/activate \
+ && python3 -m pip install .
+
+.PHONY: push
+push: .pip build
+ python3 -m twine upload dist/*
+
+.PHONY: push.test
+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 \
+ -o -name .mypy_cache \
+ -exec rm -r "{}" +
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..942c53f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,473 @@
+# asciinema
+
+[![Build Status](https://github.com/asciinema/asciinema/actions/workflows/asciinema.yml/badge.svg)](https://github.com/asciinema/asciinema/actions/workflows/asciinema.yml)
+[![PyPI](https://img.shields.io/pypi/v/asciinema.svg)](https://pypi.org/project/asciinema/)
+[![license](http://img.shields.io/badge/license-GNU-blue.svg)](https://raw.githubusercontent.com/asciinema/asciinema/master/LICENSE)
+
+Terminal session recorder and the best companion of
+[asciinema.org](https://asciinema.org).
+
+[![demo](https://asciinema.org/a/335480.svg)](https://asciinema.org/a/335480?autoplay=1)
+
+## Quick intro
+
+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))
+using [pipx](https://pypa.github.io/pipx/) (if you have it):
+
+```sh
+pipx install asciinema
+```
+
+If you don't have pipx, install using pip with your preferred Python version:
+
+```sh
+python3 -m pip install asciinema
+```
+
+Record your first session:
+
+```sh
+asciinema rec first.cast
+```
+
+Now replay it with double speed:
+
+```sh
+asciinema play -s 2 first.cast
+```
+
+Or with normal speed but with idle time limited to 2 seconds:
+
+```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
+watch. Try it.
+
+If you want to watch and share it on the web, upload it:
+
+```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)
+instance, and prints a secret link you can use to watch your recording in a web
+browser.
+
+You can record and upload in one step by omitting the filename:
+
+```sh
+asciinema rec
+```
+
+You'll be asked to confirm the upload when the recording is done. Nothing is
+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 from PyPI
+
+[pypi]: https://pypi.python.org/pypi/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):
+
+```sh
+pipx install asciinema
+```
+
+Or with pip (using your preferred Python version):
+
+```sh
+python3 -m pip install asciinema
+```
+
+Installing from [PyPI] is the recommended way of installation, which gives you the latest released version.
+
+### Native packages
+
+asciinema is included in repositories of most popular package managers on Mac OS
+X, Linux and FreeBSD. Look for package named `asciinema`. See the
+[list of available packages](https://asciinema.org/docs/installation).
+
+### Running latest version from source code checkout
+
+If you can't use Python package or native package for your OS is outdated you
+can clone the repo and run asciinema straight from the checkout.
+
+Clone the repo:
+
+```sh
+git clone https://github.com/asciinema/asciinema.git
+cd asciinema
+```
+
+If you want latest stable version:
+
+```sh
+git checkout master
+```
+
+If you want current development version:
+
+```sh
+git checkout develop
+```
+
+Then run it with:
+
+```sh
+python3 -m asciinema --version
+```
+
+### Docker image
+
+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.
+
+```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`):
+
+```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
+Usage section for commands and options).
+
+There's not much software installed in this image though. In most cases you may
+want to install extra programs before recording. One option is to derive new
+image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash`
+as the entrypoint, install extra packages and manually start `asciinema rec`:
+
+```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
+
+asciinema is composed of multiple commands, similar to `git`, `apt-get` or
+`brew`.
+
+When you run `asciinema` with no arguments help message is displayed, listing
+all available commands with their options.
+
+### `rec [filename]`
+
+**Record terminal session.**
+
+By running `asciinema rec [filename]` you start a new recording session. The
+command (process) that is recorded can be specified with `-c` option (see
+below), and defaults to `$SHELL` which is what you want in most cases.
+
+You can temporarily pause the capture of your terminal by pressing
+<kbd>Ctrl+\\</kbd>. This is useful when you want to execute some commands during
+the recording session that should not be captured (e.g. pasting secrets). Resume
+by pressing <kbd>Ctrl+\\</kbd> again. When pausing desktop notification is
+displayed so you're sure the sensitive output won't be captured in the
+recording.
+
+Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type
+`exit`). If the recorded process is not a shell then recording finishes when
+the process exits.
+
+If the `filename` argument is omitted then (after asking for confirmation) the
+resulting asciicast is uploaded to
+[asciinema-server](https://github.com/asciinema/asciinema-server) (by default to
+asciinema.org), where it can be watched and shared.
+
+If the `filename` 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 `asciinema play <filename>` and/or uploaded to asciinema server
+with `asciinema upload <filename>`.
+
+`ASCIINEMA_REC=1` is added to recorded process environment variables. This
+can be used by your shell's config file (`.bashrc`, `.zshrc`) to alter the
+prompt or play a sound when the shell is being recorded.
+
+Available options:
+
+- `--stdin` - Enable stdin (keyboard) recording (see below)
+- `--append` - Append to existing recording
+- `--raw` - Save raw STDOUT output, without timing information or other metadata
+- `--overwrite` - Overwrite the recording if it already exists
+- `-c, --command=<command>` - Specify command to record, defaults to $SHELL
+- `-e, --env=<var-names>` - List of environment variables to capture, defaults
+ to `SHELL,TERM`
+- `-t, --title=<title>` - Specify the title of the asciicast
+- `-i, --idle-time-limit=<sec>` - Limit recorded terminal inactivity to max `<sec>` seconds
+- `--cols=<n>` - Override terminal columns for recorded process
+- `--rows=<n>` - Override terminal rows for recorded process
+- `-y, --yes` - Answer "yes" to all prompts (e.g. upload confirmation)
+- `-q, --quiet` - Be quiet, suppress all notices/warnings (implies -y)
+
+Stdin recording allows for capturing of all characters typed in by the user in
+the currently recorded shell. This may be used by a player (e.g.
+[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
+`--stdin` option.
+
+### `play <filename>`
+
+**Replay recorded asciicast in a terminal.**
+
+This command replays given asciicast (as recorded by `rec` command) directly in
+your terminal.
+
+Following keyboard shortcuts are available by default:
+
+- <kbd>Space</kbd> - toggle pause,
+- <kbd>.</kbd> - step through a recording a frame at a time (when paused),
+- <kbd>Ctrl+C</kbd> - exit.
+
+See "Configuration file" section for information on how to customize the
+keyboard shortcuts.
+
+Playing from a local file:
+
+```sh
+asciinema play /path/to/asciicast.cast
+```
+
+Playing from HTTP(S) URL:
+
+```sh
+asciinema play https://asciinema.org/a/22124.cast
+asciinema play http://example.com/demo.cast
+```
+
+Playing from asciicast page URL (requires `<link rel="alternate" type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
+
+```sh
+asciinema play https://asciinema.org/a/22124
+asciinema play http://example.com/blog/post.html
+```
+
+Playing from stdin:
+
+```sh
+cat /path/to/asciicast.cast | asciinema play -
+ssh user@host cat asciicast.cast | asciinema play -
+```
+
+Playing from IPFS:
+
+```sh
+asciinema play dweb:/ipfs/QmNe7FsYaHc9SaDEAEXbaagAzNw9cH7YbzN4xV7jV1MCzK/ascii.cast
+```
+
+Available options:
+
+- `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
+- `-s, --speed=<factor>` - Playback speed (can be fractional)
+
+> For the best playback experience it is recommended to run `asciinema play` in
+> a terminal of dimensions not smaller than the one used for recording, as
+> there's no "transcoding" of control sequences for new terminal size.
+
+### `cat <filename>`
+
+**Print full output of recorded asciicast to a terminal.**
+
+While `asciinema play <filename>` replays the recorded session using timing
+information saved in the asciicast, `asciinema cat <filename>` dumps the full
+output (including all escape sequences) to a terminal immediately.
+
+`asciinema cat existing.cast >output.txt` gives the same result as recording via
+`asciinema rec --raw output.txt`.
+
+### `upload <filename>`
+
+**Upload recorded asciicast to asciinema.org site.**
+
+This command uploads given asciicast (recorded by `rec` command) to
+asciinema.org, where it can be watched and shared.
+
+`asciinema rec demo.cast` + `asciinema play demo.cast` + `asciinema upload demo.cast` is a nice combo if you want to review an asciicast before
+publishing it on asciinema.org.
+
+### `auth`
+
+**Link your install ID with your asciinema.org user account.**
+
+If you want to manage your recordings (change title/theme, delete) at
+asciinema.org you need to link your "install ID" with asciinema.org user
+account.
+
+This command displays the URL to open in a web browser to do that. 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
+`$HOME/.config/asciinema/install-id`. Its purpose is to connect local machine
+with uploaded recordings, so they can later be associated with asciinema.org
+account. This way we decouple uploading from account creation, allowing them to
+happen in any order.
+
+> A new install ID is generated on each machine and system user account you use
+> asciinema on, so in order to keep all recordings under a single asciinema.org
+> account you need to run `asciinema auth` on all of those machines.
+
+> asciinema versions prior to 2.0 confusingly referred to install ID as "API
+> token".
+
+## Hosting the recordings on the web
+
+As mentioned in the `Usage > rec` section above, if the `filename` argument to
+`asciinema rec` is omitted then the recorded asciicast is uploaded to
+[asciinema.org](https://asciinema.org). You can watch it there and share it via
+secret URL.
+
+If you prefer to host the recordings yourself, you can do so by either:
+
+- recording to a file (`asciinema rec demo.cast`), and using [asciinema's
+ standalone web
+ player](https://github.com/asciinema/asciinema-player#self-hosting-quick-start)
+ in your HTML page, or
+- setting up your own
+ [asciinema-server](https://github.com/asciinema/asciinema-server) instance,
+ and [setting API URL
+ accordingly](https://github.com/asciinema/asciinema-server/wiki/Installation-guide#using-asciinema-recorder-with-your-instance).
+
+## Configuration file
+
+You can configure asciinema by creating config file at
+`$HOME/.config/asciinema/config`.
+
+Configuration is split into sections (`[api]`, `[record]`, `[play]`). Here's a
+list of all available options for each section:
+
+```ini
+[api]
+
+; API server URL, default: https://asciinema.org
+; If you run your own instance of asciinema-server then set its address here
+; It can also be overriden by setting ASCIINEMA_API_URL environment variable
+url = https://asciinema.example.com
+
+[record]
+
+; Command to record, default: $SHELL
+command = /bin/bash -l
+
+; Enable stdin (keyboard) recording, default: no
+stdin = yes
+
+; List of environment variables to capture, default: SHELL,TERM
+env = SHELL,TERM,USER
+
+; Limit recorded terminal inactivity to max n seconds, default: off
+idle_time_limit = 2
+
+; Answer "yes" to all interactive prompts, default: no
+yes = true
+
+; Be quiet, suppress all notices/warnings, default: no
+quiet = true
+
+; Define hotkey for pausing recording (suspending capture of output),
+; default: C-\ (control + backslash)
+pause_key = C-p
+
+; Define hotkey prefix key - when defined other recording hotkeys must
+; be preceeded by it, default: no prefix
+prefix_key = C-a
+
+[play]
+
+; Playback speed (can be fractional), default: 1
+speed = 2
+
+; Limit replayed terminal inactivity to max n seconds, default: off
+idle_time_limit = 1
+
+; Define hotkey for pausing/resuming playback,
+; default: space
+pause_key = p
+
+; Define hotkey for stepping through playback, a frame at a time,
+; default: .
+step_key = ]
+
+[notifications]
+; Desktop notifications are displayed on certain occasions, e.g. when
+; pausing/resuming the capture of terminal with C-\ keyboard shortcut.
+
+; Should desktop notifications be enabled, default: yes
+enabled = no
+
+; Custom notification command
+; asciinema automatically detects available desktop notification system
+; (notify-send on GNU/Linux, osacript/terminal-notifier on macOS). Custom
+; command can be used if needed.
+; When invoked, environment variable $TEXT contains notification text, while
+; $ICON_PATH contains path to the asciinema logo image.
+command = tmux display-message "$TEXT"
+```
+
+A very minimal config file could look like that:
+
+```ini
+[record]
+idle_time_limit = 2
+```
+
+Config directory location can be changed by setting `$ASCIINEMA_CONFIG_HOME`
+environment variable.
+
+If `$XDG_CONFIG_HOME` is set on Linux then asciinema uses
+`$XDG_CONFIG_HOME/asciinema` instead of `$HOME/.config/asciinema`.
+
+> asciinema versions prior to 1.1 used `$HOME/.asciinema`. If you have it
+> there you should `mv $HOME/.asciinema $HOME/.config/asciinema`.
+
+## Contributing
+
+If you want to contribute to this project check out
+[Contributing](https://asciinema.org/contributing) page.
+
+## Authors
+
+Developed with passion by [Marcin Kulik](http://ku1ik.com) and great open
+source [contributors](https://github.com/asciinema/asciinema/contributors).
+
+## License
+
+Copyright &copy; 2011–2021 Marcin Kulik.
+
+All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file
+for details.
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 0000000..043d1e2
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,21 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
+VAGRANTFILE_API_VERSION = "2"
+
+Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
+
+ config.vm.provider :virtualbox do |vb|
+ vb.customize ["modifyvm", :id, "--memory", "1024"]
+ end
+
+ config.vm.define "archlinux" do |c|
+ c.vm.box = "terrywang/archlinux"
+ end
+
+ config.vm.define "ubuntu" do |c|
+ c.vm.box = "ubuntu/trusty64"
+ end
+
+end
diff --git a/asciinema/__init__.py b/asciinema/__init__.py
new file mode 100644
index 0000000..7f2b6ef
--- /dev/null
+++ b/asciinema/__init__.py
@@ -0,0 +1,34 @@
+import sys
+
+__author__ = "Marcin Kulik"
+__version__ = "2.2.0"
+
+if sys.version_info < (3, 6):
+ raise ImportError("Python < 3.6 is unsupported.")
+
+# pylint: disable=wrong-import-position
+from typing import Any, Optional
+
+from .recorder import record
+
+
+def record_asciicast( # pylint: disable=too-many-arguments
+ path_: str,
+ command: Any = None,
+ append: bool = False,
+ idle_time_limit: Optional[int] = None,
+ record_stdin: bool = False,
+ title: Optional[str] = None,
+ command_env: Any = None,
+ capture_env: Any = None,
+) -> None:
+ record(
+ path_,
+ command=command,
+ append=append,
+ idle_time_limit=idle_time_limit,
+ record_stdin=record_stdin,
+ title=title,
+ command_env=command_env,
+ capture_env=capture_env,
+ )
diff --git a/asciinema/__main__.py b/asciinema/__main__.py
new file mode 100644
index 0000000..7c11928
--- /dev/null
+++ b/asciinema/__main__.py
@@ -0,0 +1,223 @@
+import argparse
+import locale
+import os
+import sys
+from typing import Any, Optional
+
+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_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:
+ raise argparse.ArgumentTypeError("must be positive")
+
+ return _value
+
+
+def maybe_str(v: Any) -> Optional[str]:
+ if v is not None:
+ return str(v)
+ return None
+
+
+def main() -> Any:
+ if locale.nl_langinfo(locale.CODESET).upper() not in [
+ "US-ASCII",
+ "UTF-8",
+ "UTF8",
+ ]:
+ sys.stderr.write(
+ "asciinema needs an ASCII or UTF-8 character encoding to run. "
+ "Check the output of `locale` command.\n"
+ )
+ return 1
+
+ try:
+ cfg = config.load()
+ except config.ConfigError as e:
+ sys.stderr.write(f"{e}\n")
+ return 1
+
+ # create the top-level parser
+ parser = argparse.ArgumentParser(
+ description="Record and share your terminal sessions, the right way.",
+ epilog="""example usage:
+ Record terminal and upload it to asciinema.org:
+ \x1b[1masciinema rec\x1b[0m
+ Record terminal to local file:
+ \x1b[1masciinema rec demo.cast\x1b[0m
+ Record terminal and upload it to asciinema.org, specifying title:
+ \x1b[1masciinema rec -t "My git tutorial"\x1b[0m
+ Record terminal to local file, limiting idle time to max 2.5 sec:
+ \x1b[1masciinema rec -i 2.5 demo.cast\x1b[0m
+ Replay terminal recording from local file:
+ \x1b[1masciinema play demo.cast\x1b[0m
+ Replay terminal recording hosted on asciinema.org:
+ \x1b[1masciinema play https://asciinema.org/a/difqlgx86ym6emrmd8u62yqu8\x1b[0m
+ Print full output of recorded session:
+ \x1b[1masciinema cat demo.cast\x1b[0m
+
+For help on a specific command run:
+ \x1b[1masciinema <command> -h\x1b[0m""", # noqa: E501
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--version", action="version", version=f"asciinema {__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(
+ "--cols",
+ help="override terminal columns for recorded process",
+ type=positive_int,
+ default=None,
+ )
+ parser_rec.add_argument(
+ "--rows",
+ help="override terminal rows for recorded process",
+ type=positive_int,
+ default=None,
+ )
+ parser_rec.add_argument(
+ "-y",
+ "--yes",
+ help='answer "yes" to all prompts (e.g. upload confirmation)',
+ action="store_true",
+ default=cfg.record_yes,
+ )
+ parser_rec.add_argument(
+ "-q",
+ "--quiet",
+ help="be quiet, suppress all notices/warnings (implies -y)",
+ action="store_true",
+ default=cfg.record_quiet,
+ )
+ parser_rec.add_argument(
+ "filename",
+ nargs="?",
+ default="",
+ help="filename/path to save the recording to",
+ )
+ parser_rec.set_defaults(cmd=RecordCommand)
+
+ # create the parser for the `play` command
+ parser_play = subparsers.add_parser("play", help="Replay terminal session")
+ parser_play.add_argument(
+ "-i",
+ "--idle-time-limit",
+ help="limit idle time during playback to given number of seconds",
+ type=positive_float,
+ default=maybe_str(cfg.play_idle_time_limit),
+ )
+ parser_play.add_argument(
+ "-s",
+ "--speed",
+ help="playback speedup (can be fractional)",
+ type=positive_float,
+ default=cfg.play_speed,
+ )
+ parser_play.add_argument(
+ "filename", help='local path, http/ipfs URL or "-" (read from stdin)'
+ )
+ parser_play.set_defaults(cmd=PlayCommand)
+
+ # create the parser for the `cat` command
+ parser_cat = subparsers.add_parser(
+ "cat", help="Print full output of terminal session"
+ )
+ parser_cat.add_argument(
+ "filename", help='local path, http/ipfs URL or "-" (read from stdin)'
+ )
+ parser_cat.set_defaults(cmd=CatCommand)
+
+ # create the parser for the `upload` command
+ parser_upload = subparsers.add_parser(
+ "upload", help="Upload locally saved terminal session to asciinema.org"
+ )
+ parser_upload.add_argument(
+ "filename", help="filename or path of local recording"
+ )
+ parser_upload.set_defaults(cmd=UploadCommand)
+
+ # create the parser for the `auth` command
+ parser_auth = subparsers.add_parser(
+ "auth", help="Manage recordings on asciinema.org account"
+ )
+ parser_auth.set_defaults(cmd=AuthCommand)
+
+ # parse the args and call whatever function was selected
+ args = parser.parse_args()
+
+ if hasattr(args, "cmd"):
+ command = args.cmd(args, cfg, os.environ)
+ code = command.execute()
+ return code
+
+ parser.print_help()
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/asciinema/api.py b/asciinema/api.py
new file mode 100644
index 0000000..61ac437
--- /dev/null
+++ b/asciinema/api.py
@@ -0,0 +1,101 @@
+import json
+import platform
+import re
+from typing import Any, Callable, Dict, Optional, Tuple, Union
+from urllib.parse import urlparse
+
+from . import __version__
+from .http_adapter import HTTPConnectionError
+from .urllib_http_adapter import URLLibHttpAdapter
+
+
+class APIError(Exception):
+ pass
+
+
+class Api:
+ def __init__(
+ self,
+ url: str,
+ user: Optional[str],
+ install_id: str,
+ http_adapter: Any = None,
+ ) -> None:
+ self.url = url
+ self.user = user
+ self.install_id = install_id
+ self.http_adapter = (
+ http_adapter if http_adapter is not None else URLLibHttpAdapter()
+ )
+
+ def hostname(self) -> Optional[str]:
+ return urlparse(self.url).hostname
+
+ def auth_url(self) -> str:
+ return f"{self.url}/connect/{self.install_id}"
+
+ def upload_url(self) -> str:
+ return f"{self.url}/api/asciicasts"
+
+ def upload_asciicast(self, path_: str) -> Tuple[Any, Any]:
+ with open(path_, "rb") as f:
+ try:
+ status, headers, body = self.http_adapter.post(
+ self.upload_url(),
+ files={"asciicast": ("ascii.cast", f)},
+ headers=self._headers(),
+ username=self.user,
+ password=self.install_id,
+ )
+ except HTTPConnectionError as e:
+ raise APIError(str(e)) from e
+
+ if status not in (200, 201):
+ self._handle_error(status, body)
+
+ if (headers.get("content-type") or "")[0:16] == "application/json":
+ result = json.loads(body)
+ else:
+ result = {"url": body}
+
+ return result, headers.get("Warning")
+
+ def _headers(self) -> Dict[str, Union[Callable[[], str], str]]:
+ return {"user-agent": self._user_agent(), "accept": "application/json"}
+
+ @staticmethod
+ def _user_agent() -> str:
+ os = re.sub("([^-]+)-(.*)", "\\1/\\2", platform.platform())
+
+ return (
+ f"asciinema/{__version__} {platform.python_implementation()}"
+ f"/{platform.python_version()} {os}"
+ )
+
+ @staticmethod
+ def _handle_error(status: int, body: bytes) -> None:
+ errors = {
+ 400: f"Invalid request: {body.decode('utf-8', 'replace')}",
+ 401: "Invalid or revoked install ID",
+ 404: (
+ "API endpoint not found. "
+ "This asciinema version may no longer be supported. "
+ "Please upgrade to the latest version."
+ ),
+ 413: "Sorry, your asciicast is too big.",
+ 422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
+ 503: "The server is down for maintenance. Try again in a minute.",
+ }
+
+ error = errors.get(status)
+
+ if not error:
+ if status >= 500:
+ error = (
+ "The server is having temporary problems. "
+ "Try again in a minute."
+ )
+ else:
+ error = f"HTTP status: {status}"
+
+ raise APIError(error)
diff --git a/asciinema/asciicast/__init__.py b/asciinema/asciicast/__init__.py
new file mode 100644
index 0000000..1cd4a92
--- /dev/null
+++ b/asciinema/asciicast/__init__.py
@@ -0,0 +1,134 @@
+import codecs
+import gzip
+import os
+import sys
+import urllib.error
+from codecs import StreamReader
+from html.parser import HTMLParser
+from typing import Any, List, TextIO, Union
+from urllib.parse import urlparse, urlunparse
+from urllib.request import Request, urlopen
+
+from . import v1, v2
+
+
+class LoadError(Exception):
+ pass
+
+
+class Parser(HTMLParser):
+ def __init__(self) -> None:
+ HTMLParser.__init__(self)
+ self.url = None
+
+ 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 <link rel="alternate"
+ # type="application/x-asciicast"
+ # href="https://...cast">
+ if tag == "link":
+ # avoid modifying function signature keyword args from base class
+ _attrs = {}
+ for k, v in attrs:
+ _attrs[k] = v
+
+ if _attrs.get("rel") == "alternate":
+ type_ = _attrs.get("type")
+ if type_ in (
+ "application/asciicast+json",
+ "application/x-asciicast",
+ ):
+ self.url = _attrs.get("href")
+
+
+def open_url(url: str) -> Union[StreamReader, TextIO]:
+ if url == "-":
+ return sys.stdin
+
+ if url.startswith("ipfs://"):
+ url = f"https://ipfs.io/ipfs/{url[7:]}"
+ elif url.startswith("dweb:/ipfs/"):
+ url = f"https://ipfs.io/{url[5:]}"
+
+ if url.startswith("http:") or url.startswith("https:"):
+ req = Request(url)
+ req.add_header("Accept-Encoding", "gzip")
+ with urlopen(req) as response:
+ body = response
+ url = response.geturl() # final URL after redirects
+
+ if response.headers["Content-Encoding"] == "gzip":
+ body = gzip.open(body)
+
+ 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 not new_url:
+ raise LoadError(
+ '<link rel="alternate" '
+ 'type="application/x-asciicast" '
+ 'href="..."> '
+ "not found in fetched HTML document"
+ )
+
+ if "://" not in new_url:
+ base_url = urlparse(url)
+
+ if 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")
+
+
+class open_from_url:
+ FORMAT_ERROR = "only asciicast v1 and v2 formats can be opened"
+
+ def __init__(self, url: str) -> None:
+ self.url = url
+ self.file: Union[StreamReader, TextIO, None] = None
+ self.context: Any = None
+
+ def __enter__(self) -> Any:
+ try:
+ self.file = open_url(self.url)
+ first_line = self.file.readline()
+
+ try: # try v2 first
+ self.context = v2.open_from_file(first_line, self.file)
+ return self.context.__enter__()
+ except v2.LoadError:
+ try: # try v1 next
+ self.context = v1.open_from_file(first_line, self.file)
+ return self.context.__enter__()
+ except v1.LoadError as e:
+ raise LoadError(self.FORMAT_ERROR) from e
+
+ except (OSError, urllib.error.HTTPError) as e:
+ raise LoadError(str(e)) from e
+
+ 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
new file mode 100644
index 0000000..87f81c3
--- /dev/null
+++ b/asciinema/asciicast/events.py
@@ -0,0 +1,41 @@
+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
+ delay = time - prev_time
+ prev_time = time
+ yield [delay, type_, data]
+
+
+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
+ time = time + delay
+ yield [time, type_, data]
+
+
+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
+ )
+ return 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
new file mode 100644
index 0000000..b52441f
--- /dev/null
+++ b/asciinema/asciicast/raw.py
@@ -0,0 +1,48 @@
+import os
+import sys
+from os import path
+from typing import Any, Callable, Optional
+
+from ..file_writer import file_writer
+
+
+class writer(file_writer):
+ def __init__( # pylint: disable=too-many-arguments
+ self,
+ path_: str,
+ metadata: Any = None,
+ append: bool = False,
+ buffering: int = 0,
+ on_error: Optional[Callable[[str], None]] = None,
+ ) -> None:
+ super().__init__(path_, on_error)
+
+ if (
+ append and path.exists(path_) and os.stat(path_).st_size == 0
+ ): # true for pipes
+ append = False
+
+ self.buffering = buffering
+ self.mode: str = "ab" if append else "wb"
+ self.metadata = metadata
+
+ def write_stdout(self, _ts: float, data: Any) -> None:
+ self._write(data)
+
+ # pylint: disable=no-self-use
+ def write_stdin(self, ts: float, data: Any) -> None:
+ pass
+
+ # pylint: disable=consider-using-with
+ def _open_file(self) -> None:
+ if self.path == "-":
+ self.file = os.fdopen(
+ sys.stdout.fileno(),
+ mode=self.mode,
+ buffering=self.buffering,
+ closefd=False,
+ )
+ else:
+ self.file = open(
+ self.path, mode=self.mode, buffering=self.buffering
+ )
diff --git a/asciinema/asciicast/v1.py b/asciinema/asciicast/v1.py
new file mode 100644
index 0000000..a1f754b
--- /dev/null
+++ b/asciinema/asciicast/v1.py
@@ -0,0 +1,62 @@
+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
+
+
+class LoadError(Exception):
+ pass
+
+
+class Asciicast:
+ 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) -> Dict[str, Any]:
+ keys = ["width", "height", "duration", "command", "title", "env"]
+ header = {
+ k: v
+ for k, v in self.__attrs.items()
+ if k in keys and v is not None
+ }
+ return header
+
+ def __stdout_events(self) -> Generator[List[Any], None, None]:
+ for time, data in self.__attrs["stdout"]:
+ yield [time, "o", data]
+
+ def events(self) -> Any:
+ return self.stdout_events()
+
+ def stdout_events(self) -> Generator[List[Any], None, None]:
+ return to_absolute_time(self.__stdout_events())
+
+
+class open_from_file:
+ FORMAT_ERROR: str = "only asciicast v1 format can be opened"
+
+ def __init__(
+ self, first_line: str, file: Union[TextIO, StreamReader]
+ ) -> None:
+ self.first_line = first_line
+ self.file = file
+
+ def __enter__(self) -> Optional[Asciicast]:
+ try:
+ attrs = json.loads(self.first_line + self.file.read())
+
+ if attrs.get("version") == 1:
+ return Asciicast(attrs)
+ raise LoadError(self.FORMAT_ERROR)
+ except JSONDecodeError as e:
+ raise LoadError(self.FORMAT_ERROR) from e
+
+ 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
new file mode 100644
index 0000000..c9dcb8f
--- /dev/null
+++ b/asciinema/asciicast/v2.py
@@ -0,0 +1,171 @@
+import codecs
+import json
+import os
+import sys
+from codecs import StreamReader
+from json.decoder import JSONDecodeError
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ Generator,
+ List,
+ Optional,
+ TextIO,
+ Union,
+)
+
+from ..file_writer import file_writer
+
+
+class LoadError(Exception):
+ pass
+
+
+class Asciicast:
+ 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) -> Generator[Any, None, None]:
+ for line in self.__file:
+ yield json.loads(line)
+
+ 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: 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: str, file: Union[StreamReader, TextIO]
+ ) -> None:
+ self.first_line = first_line
+ self.file = file
+
+ def __enter__(self) -> Asciicast:
+ try:
+ 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)
+ raise LoadError(self.FORMAT_ERROR)
+ except JSONDecodeError as e:
+ raise LoadError(self.FORMAT_ERROR) from e
+
+ def __exit__(
+ self, exc_type: str, exc_value: str, exc_traceback: str
+ ) -> None:
+ self.file.close()
+
+
+def get_duration(path_: str) -> Any:
+ with open(path_, mode="rt", encoding="utf-8") as f:
+ first_line = f.readline()
+ with open_from_file(first_line, f) as a:
+ last_frame = None
+ for last_frame in a.stdout_events():
+ pass
+ return last_frame[0]
+
+
+def build_header(
+ width: 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 isinstance(header["width"], int)
+ assert isinstance(header["height"], int)
+
+ if "timestamp" in header:
+ assert isinstance(header["timestamp"], (int, float))
+
+ return header
+
+
+class writer(file_writer):
+ def __init__( # pylint: disable=too-many-arguments
+ self,
+ path_: str,
+ metadata: Any = None,
+ append: bool = False,
+ buffering: int = 1,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ on_error: Optional[Callable[[str], None]] = None,
+ ) -> None:
+ super().__init__(path_, on_error)
+
+ self.buffering = buffering
+ self.stdin_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
+ self.stdout_decoder = codecs.getincrementaldecoder("UTF-8")("replace")
+
+ if append:
+ self.mode = "a"
+ self.header = None
+ else:
+ self.mode = "w"
+ self.header = build_header(width, height, metadata or {})
+
+ def __enter__(self) -> Any:
+ self._open_file()
+
+ if self.header:
+ self.__write_line(self.header)
+
+ return self
+
+ 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: 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)
+
+ # pylint: disable=consider-using-with
+ def _open_file(self) -> None:
+ if self.path == "-":
+ self.file = os.fdopen(
+ sys.stdout.fileno(),
+ mode=self.mode,
+ buffering=self.buffering,
+ encoding="utf-8",
+ closefd=False,
+ )
+ else:
+ self.file = open(
+ self.path,
+ mode=self.mode,
+ buffering=self.buffering,
+ encoding="utf-8",
+ )
+
+ def __write_event(self, ts: float, etype: str, data: str) -> None:
+ self.__write_line([round(ts, 6), etype, data])
+
+ def __write_line(self, obj: Any) -> None:
+ line = json.dumps(
+ obj, ensure_ascii=False, indent=None, separators=(", ", ": ")
+ )
+
+ self._write(f"{line}\n")
diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py
new file mode 100644
index 0000000..fbfcbb8
--- /dev/null
+++ b/asciinema/async_worker.py
@@ -0,0 +1,46 @@
+from typing import Any, Optional
+
+try:
+ # Importing synchronize is to detect platforms where
+ # multiprocessing does not work (python issue 3770)
+ # and cause an ImportError. Otherwise it will happen
+ # later when trying to use Queue().
+ from multiprocessing import Process, Queue, synchronize
+
+ # 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:
+ def __init__(self) -> None:
+ self.queue: Queue[Any] = Queue()
+ self.process: Optional[Process] = None
+
+ def __enter__(self) -> Any:
+ self.process = Process(target=self.run)
+ self.process.start()
+ return self
+
+ 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()
+
+ if self.process.exitcode != 0:
+ raise RuntimeError(
+ f"worker process exited with code {self.process.exitcode}"
+ )
+
+ def enqueue(self, payload: Any) -> None:
+ self.queue.put(payload)
+
+ def run(self) -> None:
+ payload: Any
+ for payload in iter(self.queue.get, None):
+ # pylint: disable=no-member
+ self.perform(payload) # type: ignore[attr-defined]
diff --git a/asciinema/commands/__init__.py b/asciinema/commands/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/asciinema/commands/__init__.py
diff --git a/asciinema/commands/auth.py b/asciinema/commands/auth.py
new file mode 100644
index 0000000..1672e48
--- /dev/null
+++ b/asciinema/commands/auth.py
@@ -0,0 +1,20 @@
+from typing import Any, Dict
+
+from ..config import Config
+from .command import Command
+
+
+class AuthCommand(Command):
+ def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
+ Command.__init__(self, args, config, env)
+
+ def execute(self) -> None:
+ self.print(
+ f"Open the following URL in a web browser to link your install ID "
+ f"with your {self.api.hostname()} user account:\n\n"
+ f"{self.api.auth_url()}\n\n"
+ "This will associate all recordings uploaded from this machine "
+ "(past and future ones) to your account"
+ ", and allow you to manage them (change title/theme, delete) at "
+ f"{self.api.hostname()}."
+ )
diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py
new file mode 100644
index 0000000..ce5ac93
--- /dev/null
+++ b/asciinema/commands/cat.py
@@ -0,0 +1,28 @@
+import sys
+from typing import Any, Dict
+
+from .. import asciicast
+from ..config import Config
+from ..tty_ import raw
+from .command import Command
+
+
+class CatCommand(Command):
+ def __init__(self, args: Any, config: Config, env: Dict[str, str]):
+ Command.__init__(self, args, config, env)
+ self.filename = args.filename
+
+ def execute(self) -> int:
+ try:
+ with open("/dev/tty", "rt", encoding="utf-8") as stdin:
+ with raw(stdin.fileno()):
+ with asciicast.open_from_url(self.filename) as a:
+ for _, _type, text in a.stdout_events():
+ sys.stdout.write(text)
+ sys.stdout.flush()
+
+ except asciicast.LoadError as e:
+ self.print_error(f"printing failed: {str(e)}")
+ return 1
+
+ return 0
diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py
new file mode 100644
index 0000000..5de55dd
--- /dev/null
+++ b/asciinema/commands/command.py
@@ -0,0 +1,34 @@
+import os
+import sys
+from typing import Any, Dict, Optional
+
+from ..api import Api
+from ..config import Config
+
+
+class Command:
+ 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: 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:
+ self.print(f"asciinema: {text}", color=2)
+
+ def print_warning(self, text: str) -> None:
+ self.print(f"asciinema: {text}", color=3)
+
+ def print_error(self, text: str) -> None:
+ self.print(f"asciinema: {text}", color=1, force=True)
diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py
new file mode 100644
index 0000000..4f135e7
--- /dev/null
+++ b/asciinema/commands/play.py
@@ -0,0 +1,40 @@
+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: Any,
+ config: Config,
+ env: Dict[str, str],
+ player: Optional[Player] = None,
+ ) -> None:
+ Command.__init__(self, args, config, env)
+ self.filename = args.filename
+ self.idle_time_limit = args.idle_time_limit
+ self.speed = args.speed
+ self.player = player if player is not None else Player()
+ self.key_bindings = {
+ "pause": config.play_pause_key,
+ "step": config.play_step_key,
+ }
+
+ def execute(self) -> int:
+ try:
+ with asciicast.open_from_url(self.filename) as a:
+ self.player.play(
+ a, self.idle_time_limit, self.speed, self.key_bindings
+ )
+
+ except asciicast.LoadError as e:
+ self.print_error(f"playback failed: {str(e)}")
+ return 1
+ except KeyboardInterrupt:
+ return 1
+
+ return 0
diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py
new file mode 100644
index 0000000..d60cdf5
--- /dev/null
+++ b/asciinema/commands/record.py
@@ -0,0 +1,165 @@
+import os
+import sys
+from tempfile import NamedTemporaryFile
+from typing import Any, Dict, Optional
+
+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): # pylint: disable=too-many-instance-attributes
+ def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
+ Command.__init__(self, args, config, env)
+ self.quiet = args.quiet
+ self.filename = args.filename
+ self.record_stdin = args.stdin
+ self.command = args.command
+ self.env_whitelist = args.env
+ self.title = args.title
+ self.assume_yes = args.yes or args.quiet
+ self.idle_time_limit = args.idle_time_limit
+ self.cols_override = args.cols
+ self.rows_override = args.rows
+ self.append = args.append
+ self.overwrite = args.overwrite
+ self.raw = args.raw
+ self.writer = raw.writer if args.raw else v2.writer
+ self.notifier = notifier.get_notifier(
+ config.notifications_enabled, config.notifications_command
+ )
+ self.env = env
+ self.key_bindings = {
+ "prefix": config.record_prefix_key,
+ "pause": config.record_pause_key,
+ }
+
+ # pylint: disable=too-many-branches
+ # pylint: disable=too-many-return-statements
+ # pylint: disable=too-many-statements
+ def execute(self) -> int:
+ upload = False
+ append = self.append
+
+ if self.filename == "":
+ if self.raw:
+ self.print_error(
+ "filename required when recording in raw mode"
+ )
+ return 1
+ self.filename = _tmp_path()
+ upload = True
+
+ 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
+
+ if os.stat(self.filename).st_size > 0 and self.overwrite:
+ os.remove(self.filename)
+ append = False
+
+ 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"
+ )
+ self.print_error(
+ "use --append option "
+ "if you want to append to existing recording"
+ )
+ return 1
+
+ elif append:
+ self.print_warning(
+ f"{self.filename} does not exist, not appending"
+ )
+ append = False
+
+ if append:
+ self.print_info(f"appending to asciicast at {self.filename}")
+ else:
+ self.print_info(f"recording asciicast to {self.filename}")
+
+ if self.command:
+ self.print_info("""exit opened program when you're done""")
+ else:
+ self.print_info(
+ """press <ctrl-d> or type "exit" when you're done"""
+ )
+
+ vars_: Any = filter(
+ None,
+ map(
+ (lambda var: var.strip()), # type: ignore
+ self.env_whitelist.split(","),
+ ),
+ )
+
+ try:
+ recorder.record(
+ self.filename,
+ command=self.command,
+ append=append,
+ title=self.title,
+ idle_time_limit=self.idle_time_limit,
+ command_env=self.env,
+ capture_env=vars_,
+ record_stdin=self.record_stdin,
+ writer=self.writer,
+ notify=self.notifier.notify,
+ key_bindings=self.key_bindings,
+ cols_override=self.cols_override,
+ rows_override=self.rows_override,
+ )
+ except v2.LoadError:
+ self.print_error(
+ "can only append to asciicast v2 format recordings"
+ )
+ return 1
+
+ self.print_info("recording finished")
+
+ if upload:
+ if not self.assume_yes:
+ self.print_info(
+ f"press <enter> to upload to {self.api.hostname()}"
+ ", <ctrl-c> to save locally"
+ )
+ try:
+ sys.stdin.readline()
+ except KeyboardInterrupt:
+ self.print("\r", end="")
+ self.print_info(f"asciicast saved to {self.filename}")
+ return 0
+
+ try:
+ result, warn = self.api.upload_asciicast(self.filename)
+
+ if warn:
+ self.print_warning(warn)
+
+ os.remove(self.filename)
+ self.print(result.get("message") or result["url"])
+
+ except APIError as e:
+ self.print("\r\x1b[A", end="")
+ self.print_error(f"upload failed: {str(e)}")
+ self.print_error(
+ f"retry later by running: asciinema upload {self.filename}"
+ )
+ return 1
+ else:
+ self.print_info(f"asciicast saved to {self.filename}")
+
+ return 0
+
+
+def _tmp_path() -> Optional[str]:
+ return NamedTemporaryFile(suffix="-ascii.cast", delete=False).name
diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py
new file mode 100644
index 0000000..57af7e7
--- /dev/null
+++ b/asciinema/commands/upload.py
@@ -0,0 +1,33 @@
+from typing import Any
+
+from ..api import APIError
+from ..config import Config
+from .command import Command
+
+
+class UploadCommand(Command):
+ def __init__(self, args: Any, config: Config, env: Any) -> None:
+ Command.__init__(self, args, config, env)
+ self.filename = args.filename
+
+ def execute(self) -> int:
+ try:
+ result, warn = self.api.upload_asciicast(self.filename)
+
+ if warn:
+ self.print_warning(warn)
+
+ self.print(result.get("message") or result["url"])
+
+ except OSError as e:
+ self.print_error(f"upload failed: {str(e)}")
+ return 1
+
+ except APIError as e:
+ self.print_error(f"upload failed: {str(e)}")
+ self.print_error(
+ f"retry later by running: asciinema upload {self.filename}"
+ )
+ return 1
+
+ return 0
diff --git a/asciinema/config.py b/asciinema/config.py
new file mode 100644
index 0000000..51718a2
--- /dev/null
+++ b/asciinema/config.py
@@ -0,0 +1,220 @@
+import configparser
+import os
+from os import path
+from typing import Any, Dict, Optional
+from uuid import uuid4
+
+DEFAULT_API_URL: str = "https://asciinema.org"
+DEFAULT_RECORD_ENV: str = "SHELL,TERM"
+
+
+class ConfigError(Exception):
+ pass
+
+
+class Config:
+ def __init__(
+ self,
+ config_home: Any,
+ env: Optional[Dict[str, str]] = None,
+ ) -> None:
+ self.config_home = config_home
+ self.config_file_path = path.join(config_home, "config")
+ self.install_id_path = path.join(self.config_home, "install-id")
+ self.config = configparser.ConfigParser()
+ self.config.read(self.config_file_path)
+ self.env = env if env is not None else os.environ
+
+ def upgrade(self) -> None:
+ try:
+ self.install_id
+ except ConfigError:
+ id_ = (
+ self.__api_token()
+ or self.__user_token()
+ or self.__gen_install_id()
+ )
+ self.__save_install_id(id_)
+
+ items = {
+ name: dict(section) for (name, section) in self.config.items()
+ }
+ if items in (
+ {"DEFAULT": {}, "api": {"token": id_}},
+ {"DEFAULT": {}, "user": {"token": id_}},
+ ):
+ os.remove(self.config_file_path)
+
+ if self.env.get("ASCIINEMA_API_TOKEN"):
+ raise ConfigError(
+ "ASCIINEMA_API_TOKEN variable is no longer supported"
+ ", please use ASCIINEMA_INSTALL_ID instead"
+ )
+
+ def __read_install_id(self) -> Optional[str]:
+ p = self.install_id_path
+ if path.isfile(p):
+ with open(p, "r", encoding="utf-8") as f:
+ return f.read().strip()
+ return None
+
+ @staticmethod
+ def __gen_install_id() -> str:
+ return f"{uuid4()}"
+
+ def __save_install_id(self, id_: str) -> None:
+ self.__create_config_home()
+
+ with open(self.install_id_path, "w", encoding="utf-8") as f:
+ f.write(id_)
+
+ def __create_config_home(self) -> None:
+ if not path.exists(self.config_home):
+ os.makedirs(self.config_home)
+
+ def __api_token(self) -> Optional[str]:
+ try:
+ return self.config.get("api", "token")
+ except (configparser.NoOptionError, configparser.NoSectionError):
+ return None
+
+ def __user_token(self) -> Optional[str]:
+ try:
+ return self.config.get("user", "token")
+ except (configparser.NoOptionError, configparser.NoSectionError):
+ return None
+
+ @property
+ def install_id(self) -> str:
+ id_ = self.env.get("ASCIINEMA_INSTALL_ID") or self.__read_install_id()
+
+ if id_:
+ return id_
+ raise ConfigError("no install ID found")
+
+ @property
+ 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) -> bool:
+ return self.config.getboolean("record", "stdin", fallback=False)
+
+ @property
+ def record_command(self) -> Optional[str]:
+ return self.config.get("record", "command", fallback=None)
+
+ @property
+ def record_env(self) -> str:
+ return self.config.get("record", "env", fallback=DEFAULT_RECORD_ENV)
+
+ @property
+ def record_idle_time_limit(self) -> Optional[float]:
+ fallback = self.config.getfloat(
+ "record", "maxwait", fallback=None
+ ) # pre 2.0
+ return self.config.getfloat(
+ "record", "idle_time_limit", fallback=fallback
+ )
+
+ @property
+ def record_yes(self) -> bool:
+ return self.config.getboolean("record", "yes", fallback=False)
+
+ @property
+ def record_quiet(self) -> bool:
+ return self.config.getboolean("record", "quiet", fallback=False)
+
+ @property
+ def record_prefix_key(self) -> Any:
+ return self.__get_key("record", "prefix")
+
+ @property
+ def record_pause_key(self) -> Any:
+ return self.__get_key("record", "pause", "C-\\")
+
+ @property
+ def play_idle_time_limit(self) -> Optional[float]:
+ fallback = self.config.getfloat(
+ "play", "maxwait", fallback=None
+ ) # pre 2.0
+ return self.config.getfloat(
+ "play", "idle_time_limit", fallback=fallback
+ )
+
+ @property
+ def play_speed(self) -> float:
+ return self.config.getfloat("play", "speed", fallback=1.0)
+
+ @property
+ def play_pause_key(self) -> Any:
+ return self.__get_key("play", "pause", " ")
+
+ @property
+ def play_step_key(self) -> Any:
+ return self.__get_key("play", "step", ".")
+
+ @property
+ def notifications_enabled(self) -> bool:
+ return self.config.getboolean(
+ "notifications", "enabled", fallback=True
+ )
+
+ @property
+ def notifications_command(self) -> Optional[str]:
+ 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, f"{name}_key", fallback=default)
+
+ if key:
+ if len(key) == 3:
+ upper_key = key.upper()
+
+ if upper_key[0] == "C" and upper_key[1] == "-":
+ return bytes([ord(upper_key[2]) - 0x40])
+ 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: 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: Optional[str] = None
+
+ if env_asciinema_config_home:
+ config_home = env_asciinema_config_home
+ elif env_xdg_config_home:
+ config_home = path.join(env_xdg_config_home, "asciinema")
+ elif env_home:
+ if path.isfile(path.join(env_home, ".asciinema", "config")):
+ # location for versions < 1.1
+ config_home = path.join(env_home, ".asciinema")
+ else:
+ config_home = path.join(env_home, ".config", "asciinema")
+ else:
+ raise Exception(
+ "need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME"
+ )
+
+ return config_home
+
+
+def load(env: Any = None) -> Config:
+ if env is None:
+ env = os.environ
+ config = Config(get_config_home(env), env)
+ config.upgrade()
+ return config
diff --git a/asciinema/data/icon-256x256.png b/asciinema/data/icon-256x256.png
new file mode 100644
index 0000000..a0885aa
--- /dev/null
+++ b/asciinema/data/icon-256x256.png
Binary files differ
diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py
new file mode 100644
index 0000000..c07e2d9
--- /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 or noop
+
+ def __enter__(self) -> Any:
+ self._open_file()
+ return self
+
+ def __exit__(
+ self, exc_type: str, exc_value: str, exc_traceback: str
+ ) -> None:
+ assert self.file is not None
+ self.file.close()
+
+ def _open_file(self) -> None:
+ raise NotImplementedError
+
+ def _write(self, data: Any) -> None:
+ try:
+ self.file.write(data) # type: ignore
+ except BrokenPipeError as e:
+ if self.path != "-" and stat.S_ISFIFO(os.stat(self.path).st_mode):
+ self.on_error("Broken pipe, reopening...")
+ self._open_file()
+ self.on_error("Output pipe reopened successfully")
+ self.file.write(data) # type: ignore
+ else:
+ self.on_error("Output pipe broken")
+ raise e
+
+
+def noop(_: Any) -> None:
+ return None
diff --git a/asciinema/http_adapter.py b/asciinema/http_adapter.py
new file mode 100644
index 0000000..12229aa
--- /dev/null
+++ b/asciinema/http_adapter.py
@@ -0,0 +1,2 @@
+class HTTPConnectionError(Exception):
+ pass
diff --git a/asciinema/notifier.py b/asciinema/notifier.py
new file mode 100644
index 0000000..9148966
--- /dev/null
+++ b/asciinema/notifier.py
@@ -0,0 +1,121 @@
+import shutil
+import subprocess
+from os import environ, path
+from typing import Dict, List, Optional, Union
+
+
+class Notifier:
+ def __init__(self, cmd: str) -> None:
+ self.cmd = cmd
+
+ @staticmethod
+ def get_icon_path() -> Optional[str]:
+ path_ = path.join(
+ path.dirname(path.realpath(__file__)),
+ "data/icon-256x256.png",
+ )
+
+ if path.exists(path_):
+ return path_
+ return None
+
+ def args(self, _text: str) -> List[str]:
+ return ["/bin/sh", "-c", self.cmd]
+
+ def is_available(self) -> bool:
+ return shutil.which(self.cmd) is not None
+
+ def notify(self, text: 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):
+ def __init__(self) -> None:
+ super().__init__("osascript")
+
+ def args(self, text: str) -> List[str]:
+ text = text.replace('"', '\\"')
+ return [
+ self.cmd,
+ "-e",
+ f'display notification "{text}" with title "asciinema"',
+ ]
+
+
+class LibNotifyNotifier(Notifier):
+ def __init__(self) -> None:
+ super().__init__("notify-send")
+
+ def args(self, text: str) -> List[str]:
+ icon_path = self.get_icon_path()
+
+ if icon_path is not None:
+ return [self.cmd, "-i", icon_path, "asciinema", text]
+ return [self.cmd, "asciinema", text]
+
+
+class TerminalNotifier(Notifier):
+ def __init__(self) -> None:
+ super().__init__("terminal-notifier")
+
+ def args(self, text: str) -> List[str]:
+ icon_path = self.get_icon_path()
+
+ if icon_path is not None:
+ return [
+ "terminal-notifier",
+ "-title",
+ "asciinema",
+ "-message",
+ text,
+ "-appIcon",
+ icon_path,
+ ]
+ return [
+ "terminal-notifier",
+ "-title",
+ "asciinema",
+ "-message",
+ text,
+ ]
+
+
+class CustomCommandNotifier(Notifier):
+ def env(self, text: str) -> Dict[str, str]:
+ icon_path = self.get_icon_path()
+ env = environ.copy()
+ env["TEXT"] = text
+ if icon_path is not None:
+ env["ICON_PATH"] = icon_path
+ return env
+
+ def notify(self, text: 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: # pylint: disable=too-few-public-methods
+ def notify(self, text: str) -> None:
+ pass
+
+
+def get_notifier(
+ enabled: bool = True, command: Optional[str] = None
+) -> Union[Notifier, NoopNotifier]:
+ if enabled:
+ if command:
+ return CustomCommandNotifier(command)
+ for c in [TerminalNotifier, AppleScriptNotifier, LibNotifyNotifier]:
+ n = c()
+
+ if n.is_available():
+ return n
+
+ return NoopNotifier()
diff --git a/asciinema/player.py b/asciinema/player.py
new file mode 100644
index 0000000..341f656
--- /dev/null
+++ b/asciinema/player.py
@@ -0,0 +1,96 @@
+import sys
+import time
+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 .tty_ import raw, read_blocking
+
+
+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:
+ 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)
+
+ @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")
+
+ stdout = asciicast.stdout_events()
+ stdout = ev.to_relative_time(stdout)
+ stdout = ev.cap_relative_time(stdout, idle_time_limit)
+ stdout = ev.to_absolute_time(stdout)
+ stdout = ev.adjust_speed(stdout, speed)
+
+ base_time = time.time()
+ ctrl_c = False
+ paused = False
+ pause_time: Optional[float] = None
+
+ for t, _type, text in stdout:
+ delay = t - (time.time() - base_time)
+
+ while stdin and not ctrl_c and delay > 0:
+ if paused:
+ while True:
+ data = read_blocking(stdin.fileno(), 1000)
+
+ if 0x03 in data: # ctrl-c
+ ctrl_c = True
+ break
+
+ if data == pause_key:
+ paused = False
+ assert pause_time is not None
+ base_time += time.time() - pause_time
+ break
+
+ if data == step_key:
+ delay = 0
+ pause_time = time.time()
+ base_time = pause_time - t
+ break
+ else:
+ data = read_blocking(stdin.fileno(), delay)
+
+ if not data:
+ break
+
+ if 0x03 in data: # ctrl-c
+ ctrl_c = True
+ break
+
+ if data == pause_key:
+ paused = True
+ pause_time = time.time()
+ slept = t - (pause_time - base_time)
+ delay = delay - slept
+
+ if ctrl_c:
+ break
+
+ sys.stdout.write(text)
+ sys.stdout.flush()
diff --git a/asciinema/pty_.py b/asciinema/pty_.py
new file mode 100644
index 0000000..2c62717
--- /dev/null
+++ b/asciinema/pty_.py
@@ -0,0 +1,183 @@
+import array
+import errno
+import fcntl
+import os
+import pty
+import select
+import signal
+import struct
+import termios
+import time
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from .tty_ import raw
+
+EXIT_SIGNALS = [
+ signal.SIGCHLD,
+ signal.SIGHUP,
+ signal.SIGTERM,
+ signal.SIGQUIT,
+]
+
+
+# pylint: disable=too-many-arguments,too-many-locals,too-many-statements
+def record(
+ command: Any,
+ env: Dict[str, str],
+ writer: Any,
+ get_tty_size: Callable[[], Tuple[int, int]],
+ notify: Callable[[str], None],
+ key_bindings: Dict[str, Any],
+ tty_stdin_fd: int = pty.STDIN_FILENO,
+ tty_stdout_fd: int = pty.STDOUT_FILENO,
+) -> None:
+ pty_fd: Any = None
+ start_time: Optional[float] = None
+ pause_time: Optional[float] = None
+ prefix_mode: bool = False
+ prefix_key = key_bindings.get("prefix")
+ pause_key = key_bindings.get("pause")
+
+ def set_pty_size() -> None:
+ cols, rows = get_tty_size()
+ buf = array.array("h", [rows, cols, 0, 0])
+ fcntl.ioctl(pty_fd, termios.TIOCSWINSZ, buf)
+
+ def handle_master_read(data: Any) -> None:
+ os.write(tty_stdout_fd, data)
+
+ if not pause_time:
+ assert start_time is not None
+ writer.write_stdout(time.time() - start_time, data)
+
+ def handle_stdin_read(data: Any) -> None:
+ nonlocal pause_time
+ nonlocal start_time
+ nonlocal prefix_mode
+
+ if not prefix_mode and prefix_key and data == prefix_key:
+ prefix_mode = True
+ return
+
+ if prefix_mode or (not prefix_key and data in [pause_key]):
+ prefix_mode = False
+
+ if data == pause_key:
+ if pause_time:
+ assert start_time is not None
+ start_time += time.time() - pause_time
+ pause_time = None
+ notify("Resumed recording")
+ else:
+ pause_time = time.time()
+ notify("Paused recording")
+
+ return
+
+ remaining_data = data
+ while remaining_data:
+ n = os.write(pty_fd, remaining_data)
+ remaining_data = remaining_data[n:]
+
+ if not pause_time:
+ assert start_time is not None
+ writer.write_stdin(time.time() - start_time, data)
+
+ def copy(signal_fd: int) -> None: # pylint: disable=too-many-branches
+ fds = [pty_fd, tty_stdin_fd, signal_fd]
+ stdin_fd = pty.STDIN_FILENO
+
+ if not os.isatty(stdin_fd):
+ fds.append(stdin_fd)
+
+ while True:
+ try:
+ rfds, _, _ = select.select(fds, [], [])
+ except OSError as e: # Python >= 3.3
+ if e.errno == errno.EINTR:
+ continue
+
+ if pty_fd in rfds:
+ data = os.read(pty_fd, 1024)
+
+ if not data: # Reached EOF.
+ fds.remove(pty_fd)
+ else:
+ handle_master_read(data)
+
+ if tty_stdin_fd in rfds:
+ data = os.read(tty_stdin_fd, 1024)
+
+ if not data:
+ fds.remove(tty_stdin_fd)
+ else:
+ handle_stdin_read(data)
+
+ if stdin_fd in rfds:
+ data = os.read(stdin_fd, 1024)
+
+ if not data:
+ fds.remove(stdin_fd)
+ else:
+ handle_stdin_read(data)
+
+ if signal_fd in rfds:
+ data = os.read(signal_fd, 1024)
+
+ if data:
+ signals = struct.unpack(f"{len(data)}B", data)
+
+ for sig in signals:
+ if sig in EXIT_SIGNALS:
+ os.close(pty_fd)
+ return None
+ if sig == signal.SIGWINCH:
+ set_pty_size()
+
+ pid, pty_fd = pty.fork()
+
+ if pid == pty.CHILD:
+ os.execvpe(command[0], command, env)
+
+ start_time = time.time()
+ set_pty_size()
+
+ with SignalFD(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd:
+ with raw(tty_stdin_fd):
+ try:
+ copy(sig_fd)
+ except (IOError, OSError):
+ pass
+
+ os.waitpid(pid, 0)
+
+
+class SignalFD:
+ def __init__(self, signals: List[signal.Signals]) -> None:
+ self.signals = signals
+ self.orig_handlers: List[Tuple[signal.Signals, Any]] = []
+ self.orig_wakeup_fd: Optional[int] = None
+
+ def __enter__(self) -> int:
+ r, w = os.pipe()
+ flags = fcntl.fcntl(w, fcntl.F_GETFL, 0) | os.O_NONBLOCK
+ fcntl.fcntl(w, fcntl.F_SETFL, flags)
+ self.orig_wakeup_fd = signal.set_wakeup_fd(w)
+
+ for sig, handler in self._noop_handlers(self.signals):
+ self.orig_handlers.append((sig, signal.signal(sig, handler)))
+
+ return r
+
+ def __exit__(self, type_: str, value: str, traceback: str) -> None:
+ assert self.orig_wakeup_fd is not None
+ signal.set_wakeup_fd(self.orig_wakeup_fd)
+
+ for sig, handler in self.orig_handlers:
+ signal.signal(sig, handler)
+
+ @staticmethod
+ def _noop_handlers(
+ signals: List[signal.Signals],
+ ) -> List[Tuple[signal.Signals, Any]]:
+ return list(map(lambda s: (s, lambda signal, frame: None), signals))
diff --git a/asciinema/py.typed b/asciinema/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/asciinema/py.typed
diff --git a/asciinema/recorder.py b/asciinema/recorder.py
new file mode 100644
index 0000000..14f27e5
--- /dev/null
+++ b/asciinema/recorder.py
@@ -0,0 +1,191 @@
+import os
+import time
+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
+from .asciicast.v2 import writer as w2
+from .async_worker import async_worker
+
+
+def record( # pylint: disable=too-many-arguments,too-many-locals
+ path_: str,
+ command: Optional[str] = None,
+ append: bool = False,
+ idle_time_limit: Optional[float] = None,
+ record_stdin: bool = False,
+ title: Optional[str] = None,
+ command_env: Optional[Dict[str, str]] = None,
+ capture_env: Optional[List[str]] = None,
+ writer: Type[w2] = v2.writer,
+ record_: Callable[..., None] = pty.record,
+ notify: Callable[[str], None] = lambda _: None,
+ key_bindings: Optional[Dict[str, Any]] = None,
+ cols_override: Optional[int] = None,
+ rows_override: Optional[int] = None,
+) -> None:
+ if command is None:
+ command = os.environ.get("SHELL", "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:
+ capture_env = ["SHELL", "TERM"]
+
+ time_offset: float = 0
+
+ if append and os.stat(path_).st_size > 0:
+ time_offset = v2.get_duration(path_)
+
+ with tty_fds() as (tty_stdin_fd, tty_stdout_fd), async_notifier(
+ notify
+ ) as _notifier:
+ get_tty_size = _get_tty_size(
+ tty_stdout_fd, cols_override, rows_override
+ )
+ cols, rows = get_tty_size()
+ metadata = build_metadata(
+ cols, rows, idle_time_limit, capture_env, command_env, title
+ )
+
+ sync_writer = writer(
+ path_, metadata, append, on_error=_notifier.queue.put
+ )
+
+ with async_writer(sync_writer, time_offset, record_stdin) as _writer:
+ record_(
+ ["sh", "-c", command],
+ command_env,
+ _writer,
+ get_tty_size,
+ _notifier.notify,
+ key_bindings,
+ tty_stdin_fd=tty_stdin_fd,
+ tty_stdout_fd=tty_stdout_fd,
+ )
+
+
+class tty_fds:
+ def __init__(self) -> None:
+ self.stdin_file: Optional[TextIO] = None
+ self.stdout_file: Optional[TextIO] = None
+
+ def __enter__(self) -> Tuple[int, int]:
+ try:
+ self.stdin_file = open("/dev/tty", "rt", encoding="utf_8")
+ except OSError:
+ self.stdin_file = open("/dev/null", "rt", encoding="utf_8")
+
+ try:
+ self.stdout_file = open("/dev/tty", "wt", encoding="utf_8")
+ except OSError:
+ self.stdout_file = open("/dev/null", "wt", encoding="utf_8")
+
+ return (self.stdin_file.fileno(), self.stdout_file.fileno())
+
+ def __exit__(self, type_: str, value: str, traceback: str) -> None:
+ assert self.stdin_file is not None
+ assert self.stdout_file is not None
+ self.stdin_file.close()
+ self.stdout_file.close()
+
+
+def build_metadata( # pylint: disable=too-many-arguments
+ cols: int,
+ rows: int,
+ idle_time_limit: Optional[float],
+ capture_env: List[str],
+ env: Dict[str, str],
+ title: Optional[str],
+) -> Dict[str, Any]:
+ metadata: Dict[str, Any] = {
+ "width": cols,
+ "height": rows,
+ "timestamp": int(time.time()),
+ }
+
+ if idle_time_limit is not None:
+ metadata["idle_time_limit"] = idle_time_limit
+
+ metadata["env"] = {var: env.get(var) for var in capture_env}
+
+ if title:
+ metadata["title"] = title
+
+ return metadata
+
+
+class async_writer(async_worker):
+ def __init__(
+ self, writer: 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:
+ if self.record_stdin:
+ self.enqueue([ts, "i", data])
+
+ def write_stdout(self, ts: float, data: Any) -> None:
+ self.enqueue([ts, "o", data])
+
+ def run(self) -> None:
+ with self.writer as w:
+ event: Tuple[float, str, Any]
+ for event in iter(self.queue.get, None):
+ assert event is not None
+ ts, etype, data = event
+
+ if etype == "o":
+ w.write_stdout(self.time_offset + ts, data)
+ elif etype == "i":
+ w.write_stdin(self.time_offset + ts, data)
+
+
+class async_notifier(async_worker):
+ def __init__(self, notify: Callable[[str], None]) -> None:
+ async_worker.__init__(self)
+ self._notify = notify
+
+ def notify(self, text: str) -> None:
+ self.enqueue(text)
+
+ def perform(self, text: str) -> None:
+ try:
+ self._notify(text)
+ except: # pylint: disable=bare-except # noqa: E722
+ # we catch *ALL* exceptions here because we don't want failed
+ # notification to crash the recording session
+ pass
+
+
+def _get_tty_size(
+ fd: int, cols_override: Optional[int], rows_override: Optional[int]
+) -> Callable[[], Tuple[int, int]]:
+ if cols_override is not None and rows_override is not None:
+
+ def fixed_size() -> Tuple[int, int]:
+ return (cols_override, rows_override) # type: ignore
+
+ return fixed_size
+
+ if not os.isatty(fd):
+
+ def fallback_size() -> Tuple[int, int]:
+ return (cols_override or 80, rows_override or 24)
+
+ return fallback_size
+
+ def size() -> Tuple[int, int]:
+ cols, rows = os.get_terminal_size(fd)
+ return (cols_override or cols, rows_override or rows)
+
+ return size
diff --git a/asciinema/tty_.py b/asciinema/tty_.py
new file mode 100644
index 0000000..0e851cd
--- /dev/null
+++ b/asciinema/tty_.py
@@ -0,0 +1,34 @@
+import os
+import select
+import termios as tty # avoid `Module "tty" has no attribute ...` errors
+from time import sleep
+from tty import setraw
+from typing import IO, Any, List, Optional, Union
+
+
+class raw:
+ def __init__(self, fd: Union[IO[str], int]) -> None:
+ self.fd = fd
+ self.restore: bool = False
+ self.mode: Optional[List[Any]] = None
+
+ def __enter__(self) -> None:
+ try:
+ self.mode = tty.tcgetattr(self.fd)
+ setraw(self.fd)
+ self.restore = True
+ except tty.error: # this is `termios.error`
+ pass
+
+ def __exit__(self, type_: str, value: str, traceback: str) -> None:
+ if self.restore:
+ sleep(0.01) # give the terminal time to send answerbacks
+ assert isinstance(self.mode, list)
+ tty.tcsetattr(self.fd, tty.TCSAFLUSH, self.mode)
+
+
+def read_blocking(fd: int, timeout: Any) -> bytes:
+ if fd in select.select([fd], [], [], timeout)[0]:
+ return os.read(fd, 1024)
+
+ return b""
diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py
new file mode 100644
index 0000000..de80fe3
--- /dev/null
+++ b/asciinema/urllib_http_adapter.py
@@ -0,0 +1,124 @@
+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
+from urllib.request import Request, urlopen
+from uuid import uuid4
+
+from .http_adapter import HTTPConnectionError
+
+
+class MultipartFormdataEncoder:
+ def __init__(self) -> None:
+ self.boundary = uuid4().hex
+ self.content_type = f"multipart/form-data; boundary={self.boundary}"
+
+ @classmethod
+ 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: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
+ ) -> Generator[Tuple[bytes, int], None, None]:
+ """
+ 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')
+ )
+ yield encoder("\r\n")
+ if isinstance(value, (int, float)):
+ value = str(value)
+ yield encoder(self.u(value))
+ 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(f"--{self.boundary}\r\n")
+ yield encoder(
+ self.u(
+ "content-disposition: form-data"
+ f'; name="{key}"'
+ f'; filename="{filename}"\r\n'
+ )
+ )
+ yield encoder("content-type: application/octet-stream\r\n")
+ yield encoder("\r\n")
+ data = f.read()
+ yield (data, len(data))
+ yield encoder("\r\n")
+ yield encoder(f"--{self.boundary}--\r\n")
+
+ def encode(
+ self, fields: Dict[str, Any], files: Dict[str, Tuple[str, Any]]
+ ) -> Tuple[str, bytes]:
+ body = io.BytesIO()
+ for chunk, _ in self.iter(fields, files):
+ body.write(chunk)
+ return self.content_type, body.getvalue()
+
+
+class URLLibHttpAdapter: # pylint: disable=too-few-public-methods
+ def post( # pylint: disable=too-many-arguments,too-many-locals
+ self,
+ url: str,
+ fields: Optional[Dict[str, Any]] = None,
+ files: Optional[Dict[str, Tuple[str, Any]]] = None,
+ headers: Optional[Dict[str, str]] = None,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ ) -> Tuple[Any, Optional[Dict[str, str]], bytes]:
+ # avoid dangerous mutable default arguments
+ if fields is None:
+ fields = {}
+ if files is None:
+ files = {}
+ if headers is None:
+ headers = {}
+
+ content_type, body = MultipartFormdataEncoder().encode(fields, files)
+
+ headers = headers.copy()
+ headers["content-type"] = content_type
+
+ if password:
+ encoded_auth = b64encode(
+ f"{username}:{password}".encode("utf_8")
+ ).decode("utf_8")
+ headers["authorization"] = f"Basic {encoded_auth}"
+
+ request = Request(url, data=body, headers=headers, method="POST")
+
+ try:
+ 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()
+ except (http.client.RemoteDisconnected, URLError) as e:
+ raise HTTPConnectionError(str(e)) from e
+
+ return (status, headers, body)
+
+ @staticmethod
+ def _parse_headers(response: HTTPResponse) -> Dict[str, str]:
+ headers = {k.lower(): v for k, v in response.getheaders()}
+
+ return headers
diff --git a/doc/asciicast-v1.md b/doc/asciicast-v1.md
new file mode 100644
index 0000000..de55223
--- /dev/null
+++ b/doc/asciicast-v1.md
@@ -0,0 +1,64 @@
+# asciicast file format (version 1)
+
+asciicast file is JSON file containing meta-data like duration or title of the
+recording, and the actual content printed to terminal's stdout during
+recording.
+
+Version 1 of the format was used by the asciinema recorder versions 1.0 up to 1.4.
+
+## Attributes
+
+Every asciicast includes the following set of attributes:
+
+* `version` - set to 1,
+* `width` - terminal width (number of columns),
+* `height` - terminal height (number of rows),
+* `duration` - total duration of asciicast as floating point number,
+* `command` - command that was recorded, as given via `-c` option to `rec`,
+* `title` - title of the asciicast, as given via `-t` option to `rec`,
+* `env` - map of environment variables useful for debugging playback problems,
+* `stdout` - array of "frames", see below.
+
+### Frame
+
+Frame represents an event of printing new data to terminal's stdout. It is a 2
+element array containing **delay** and **data**.
+
+**Delay** is the number of seconds that elapsed since the previous frame (or
+since the beginning of the recording in case of the 1st frame) represented as
+a floating point number, with microsecond precision.
+
+**Data** is a string containing the data that was printed to a terminal in a
+given frame. It has to be valid, UTF-8 encoded JSON string as described in
+[JSON RFC section 2.5](http://www.ietf.org/rfc/rfc4627.txt), with all
+non-printable Unicode codepoints encoded as `\uXXXX`.
+
+For example, frame `[5.4321, "foo\rbar\u0007..."]` means there was 5 seconds of
+inactivity between previous printing and printing of `foo\rbar\u0007...`.
+
+## Example asciicast
+
+A very short asciicast may look like this:
+
+ {
+ "version": 1,
+ "width": 80,
+ "height": 24,
+ "duration": 1.515658,
+ "command": "/bin/zsh",
+ "title": "",
+ "env": {
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/zsh"
+ },
+ "stdout": [
+ [
+ 0.248848,
+ "\u001b[1;31mHello \u001b[32mWorld!\u001b[0m\n"
+ ],
+ [
+ 1.001376,
+ "I am \rThis is on the next line."
+ ]
+ ]
+ }
diff --git a/doc/asciicast-v2.md b/doc/asciicast-v2.md
new file mode 100644
index 0000000..ff7dddb
--- /dev/null
+++ b/doc/asciicast-v2.md
@@ -0,0 +1,183 @@
+# asciicast file format (version 2)
+
+asciicast v2 file is [newline-delimited JSON](http://jsonlines.org/) file where:
+
+* __first line__ contains header (initial terminal size, timestamp and other
+ meta-data), encoded as JSON object,
+* __all following lines__ form an event stream, _each line_ representing a
+ separate event, encoded as 3-element JSON array.
+
+Example file:
+
+```json
+{"version": 2, "width": 80, "height": 24, "timestamp": 1504467315, "title": "Demo", "env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"}}
+[0.248848, "o", "\u001b[1;31mHello \u001b[32mWorld!\u001b[0m\n"]
+[1.001376, "o", "That was ok\rThis is better."]
+[2.143733, "o", " "]
+[6.541828, "o", "Bye!"]
+```
+
+Suggested file extension is `.cast`, suggested media type is
+`application/x-asciicast`.
+
+## Header
+
+asciicast header is JSON-encoded object containing recording meta-data.
+
+### Required header attributes:
+
+#### `version`
+
+Must be set to `2`. Integer.
+
+#### `width`
+
+Initial terminal width (number of columns). Integer.
+
+#### `height`
+
+Initial terminal height (number of rows). Integer.
+
+### Optional header attributes:
+
+#### `timestamp`
+
+Unix timestamp of the beginning of the recording session. Integer.
+
+#### `duration`
+
+Duration of the whole recording in seconds (when it's known upfront). Float.
+
+#### `idle_time_limit`
+
+Idle time limit, as given via `-i` option to `asciinema rec`. Float.
+
+This should be used by an asciicast player to reduce all terminal inactivity
+(delays between frames) to maximum of `idle_time_limit` value.
+
+#### `command`
+
+Command that was recorded, as given via `-c` option to `asciinema rec`. String.
+
+#### `title`
+
+Title of the asciicast, as given via `-t` option to `asciinema rec`. String.
+
+#### `env`
+
+Map of captured environment variables. Object (String -> String).
+
+Example env:
+
+```json
+"env": {
+ "SHELL": "/bin/bash",
+ "TERM": "xterm-256color"
+}
+```
+
+> Official asciinema recorder captures only `SHELL` and `TERM` by default. All
+> implementations of asciicast-compatible terminal recorder should not capture
+> any additional environment variables unless explicitly permitted by the user.
+
+#### `theme`
+
+Color theme of the recorded terminal. Object, with the following attributes:
+
+- `fg` - normal text color,
+- `bg` - normal background color,
+- `palette` - list of 8 or 16 colors, separated by colon character.
+
+All colors are in the CSS `#rrggbb` format.
+
+Example theme:
+
+```json
+"theme": {
+ "fg": "#d0d0d0",
+ "bg": "#212121",
+ "palette": "#151515:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#d0d0d0:#505050:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#f5f5f5"
+}
+```
+
+> A specific technique of obtaining the colors from a terminal (using xrdb,
+> requesting them from a terminal via special escape sequences etc) doesn't
+> matter as long as the recorder can save it in the above format.
+
+## Event stream
+
+Each element of the event stream is a 3-tuple encoded as JSON array:
+
+ [time, event-type, event-data]
+
+Where:
+
+* `time` (float) - indicates when this event happened, represented as the number
+ of seconds since the beginning of the recording session,
+* `event-type` (string) - one of: `"o"`, `"i"`,
+* `event-data` (any) - event specific data, described separately for each event
+ type.
+
+For example, let's look at the following line:
+
+ [1.001376, "o", "Hello world"]
+
+It represents the event which:
+
+* happened 1.001376 sec after the start of the recording session,
+* is of type `"o"` (print to stdout, see below),
+* has data `"Hello world"`.
+
+### Supported event types
+
+This section describes the event types supported in asciicast v2 format.
+
+The list is open to extension, and new event types may be added in both the
+current and future versions of the format. For example, we may add new event
+type for text overlay (subtitles display).
+
+A tool which interprets the event stream (web/cli player, post-processor) should
+ignore (or pass through) event types it doesn't understand or doesn't care
+about.
+
+#### "o" - data written to stdout
+
+Event of type `"o"` represents printing new data to terminal's stdout.
+
+`event-data` is a string containing the data that was printed to a terminal. It
+has to be valid, UTF-8 encoded JSON string as described
+in [JSON RFC section 2.5](http://www.ietf.org/rfc/rfc4627.txt), with all
+non-printable Unicode codepoints encoded as `\uXXXX`.
+
+#### "i" - data read from stdin
+
+Event of type `"i"` represents character(s) typed in by the user, or
+more specifically, data sent from terminal emulator to stdin of the recorded
+shell.
+
+`event-data` is a string containing the captured character(s). Like with `"o"`
+event, it's UTF-8 encoded JSON string, with all non-printable Unicode codepoints
+encoded as `\uXXXX`.
+
+> Official asciinema recorder doesn't capture stdin by default. All
+> implementations of asciicast-compatible terminal recorder should not capture
+> it either unless explicitly permitted by the user.
+
+## Notes on compatibility
+
+Version 2 of asciicast file format solves several problems which couldn't be
+easily fixed in the old format:
+
+* minimal memory usage when recording and replaying arbitrarily long sessions -
+ disk space is the only limit,
+* when the recording session is interrupted (computer crash, accidental close of
+ terminal window) you don't lose the whole recording,
+* it's real-time streaming friendly.
+
+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.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
diff --git a/man/Makefile b/man/Makefile
new file mode 100644
index 0000000..c635dfc
--- /dev/null
+++ b/man/Makefile
@@ -0,0 +1,4 @@
+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
new file mode 100644
index 0000000..6afa4c6
--- /dev/null
+++ b/man/asciinema.1
@@ -0,0 +1,403 @@
+.\" Automatically generated by Pandoc 2.18
+.\"
+.\" 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[R] - terminal session recorder
+.SH SYNOPSIS
+.PP
+\f[B]asciinema --version\f[R]
+.PD 0
+.P
+.PD
+\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, 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[V]git\f[R],
+\f[V]apt-get\f[R] or \f[V]brew\f[R].
+.PP
+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[R]]
+.PP
+Record terminal session.
+.PP
+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[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[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[R] argument is omitted then (after asking for
+confirmation) the resulting asciicast is uploaded to
+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[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[R] is added to recorded process environment
+variables.
+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
+Available options:
+\
+.RS
+.TP
+\f[V]--stdin\f[R]
+Enable stdin (keyboard) recording (see below)
+.TP
+\f[V]--append\f[R]
+Append to existing recording
+.TP
+\f[V]--raw\f[R]
+Save raw STDOUT output, without timing information or other metadata
+.TP
+\f[V]--overwrite\f[R]
+Overwrite the recording if it already exists
+.TP
+\f[V]-c, --command=<command>\f[R]
+Specify command to record, defaults to \f[B]$SHELL\f[R]
+.TP
+\f[V]-e, --env=<var-names>\f[R]
+List of environment variables to capture, defaults to
+\f[B]SHELL,TERM\f[R]
+.TP
+\f[V]-t, --title=<title>\f[R]
+Specify the title of the asciicast
+.TP
+\f[V]-i, --idle-time-limit=<sec>\f[R]
+Limit recorded terminal inactivity to max \f[V]<sec>\f[R] seconds
+.TP
+\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)
+.TP
+\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
+display pressed keys.
+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 a given asciicast (as recorded by \f[B]rec\f[R]
+command) directly in your terminal.
+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[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[R]
+.fi
+.PP
+Playing from asciicast page URL (requires
+\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[R]
+.fi
+.PP
+Playing from stdin:
+.IP
+.nf
+\f[C]
+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[R]
+.fi
+.TP
+Available options:
+\
+.RS
+.TP
+\f[V]-i, --idle-time-limit=<sec>\f[R]
+Limit replayed terminal inactivity to max \f[V]<sec>\f[R] seconds (can
+be fractional)
+.TP
+\f[V]-s, --speed=<factor>\f[R]
+Playback speed (can be fractional)
+.RE
+.TP
+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
+.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[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[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[R] command)
+to asciinema.org, where it can be watched and shared.
+.PP
+\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 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 your
+asciinema.org user account.
+.PP
+This command displays the URL to open in a web browser to do that.
+You may be asked to log in first.
+.PP
+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[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[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].
+.SH EXAMPLES
+.PP
+Record your first session:
+.IP
+.nf
+\f[C]
+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[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[R]
+.fi
+.PP
+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
+If you want to watch and share it on the web, upload it:
+.IP
+.nf
+\f[C]
+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>)
+instance, and prints a secret link you can use to watch your recording
+in a web browser.
+.PP
+You can record and upload in one step by omitting the filename:
+.IP
+.nf
+\f[C]
+asciinema rec
+\f[R]
+.fi
+.PP
+You\[cq]ll be asked to confirm the upload when the recording is done, so
+nothing is sent anywhere without your consent.
+.SS Tricks
+.TP
+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
+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.
+.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\[cq]s lead developer is Marcin Kulik.
+.PP
+For a list of all contributors look here:
+<https://github.com/asciinema/asciinema/contributors>
+.PP
+This Manual Page was written by Marcin Kulik with help from Kurt
+Pfeifle.
diff --git a/man/asciinema.1.md b/man/asciinema.1.md
new file mode 100644
index 0000000..ea42d41
--- /dev/null
+++ b/man/asciinema.1.md
@@ -0,0 +1,348 @@
+% ASCIINEMA(1) Version 2.0.1 | asciinema
+
+
+NAME
+====
+
+**asciinema** - terminal session recorder
+
+
+SYNOPSIS
+========
+
+| **asciinema \-\-version**
+| **asciinema** _command_ \[_options_] \[_args_]
+
+
+DESCRIPTION
+===========
+
+asciinema lets you easily record terminal sessions, replay
+them in a terminal as well as in a web browser and share them on the web.
+asciinema is Free and Open Source Software licensed under
+the GNU General Public License v3.
+
+
+COMMANDS
+========
+
+asciinema is composed of multiple commands, similar to `git`, `apt-get` or
+`brew`.
+
+When you run **asciinema** with no arguments a help message is displayed, listing
+all available commands with their options.
+
+
+rec [_filename_]
+---
+
+Record terminal session.
+
+By running **asciinema rec [filename]** you start a new recording session. The
+command (process) that is recorded can be specified with **-c** option (see
+below), and defaults to **$SHELL** which is what you want in most cases.
+
+You can temporarily pause the capture of your terminal by pressing
+<kbd>Ctrl+\\</kbd>. This is useful when you want to execute some commands during
+the recording session that should not be captured (e.g. pasting secrets). Resume
+by pressing <kbd>Ctrl+\\</kbd> again. When pausing desktop notification is
+displayed so you're sure the sensitive output won't be captured in the
+recording.
+
+Recording finishes when you exit the shell (hit <kbd>Ctrl+D</kbd> or type
+`exit`). If the recorded process is not a shell then recording finishes when
+the process exits.
+
+If the _filename_ argument is omitted then (after asking for confirmation) the
+resulting asciicast is uploaded to
+[asciinema-server](https://github.com/asciinema/asciinema-server) (by default to
+asciinema.org), where it can be watched and shared.
+
+If the _filename_ 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 **asciinema play \<filename>** and/or uploaded to asciinema server
+with **asciinema upload \<filename>**.
+
+**ASCIINEMA_REC=1** is added to recorded process environment variables. This
+can be used by your shell's config file (`.bashrc`, `.zshrc`) to alter the
+prompt or play a sound when the shell is being recorded.
+
+Available options:
+
+: &nbsp;
+
+ `--stdin`
+ : Enable stdin (keyboard) recording (see below)
+
+ `--append`
+ : Append to existing recording
+
+ `--raw`
+ : Save raw STDOUT output, without timing information or other metadata
+
+ `--overwrite`
+ : Overwrite the recording if it already exists
+
+ `-c, --command=<command>`
+ : Specify command to record, defaults to **$SHELL**
+
+ `-e, --env=<var-names>`
+ : List of environment variables to capture, defaults to **SHELL,TERM**
+
+ `-t, --title=<title>`
+ : Specify the title of the asciicast
+
+ `-i, --idle-time-limit=<sec>`
+ : Limit recorded terminal inactivity to max `<sec>` seconds
+
+ `--cols=<n>`
+ : Override terminal columns for recorded process
+
+ `--rows=<n>`
+ : Override terminal rows for recorded process
+
+ `-y, --yes`
+ : Answer "yes" to all prompts (e.g. upload confirmation)
+
+ `-q, --quiet`
+ : Be quiet, suppress all notices/warnings (implies **-y**)
+
+Stdin recording allows for capturing of all characters typed in by the user in
+the currently recorded shell. This may be used by a player (e.g.
+[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
+**--stdin** option.
+
+
+play <_filename_>
+---
+
+Replay recorded asciicast in a terminal.
+
+This command replays a given asciicast (as recorded by **rec** command) directly in
+your terminal. The asciicast can be read from a file or from *`stdin`* ('-'):
+
+Playing from a local file:
+
+ 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
+
+Playing from asciicast page URL (requires `<link rel="alternate"
+type="application/x-asciicast" href="/my/ascii.cast">` in page's HTML):
+
+ asciinema play https://asciinema.org/a/22124
+ asciinema play http://example.com/blog/post.html
+
+Playing from stdin:
+
+ 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
+
+Available options:
+
+: &nbsp;
+
+ `-i, --idle-time-limit=<sec>`
+ : Limit replayed terminal inactivity to max `<sec>` seconds (can be fractional)
+
+ `-s, --speed=<factor>`
+ : Playback speed (can be fractional)
+
+While playing the following keyboard shortcuts are available:
+
+: &nbsp;
+
+ *`Space`*
+ : Toggle pause
+
+ *`.`*
+ : Step through a recording a frame at a time (when paused)
+
+ *`Ctrl+C`*
+ : Exit
+
+Recommendation: run 'asciinema play' in a terminal of dimensions not smaller than the one
+used for recording as there's no "transcoding" of control sequences for the new terminal
+size.
+
+
+cat <_filename_>
+---
+
+Print full output of recorded asciicast to a terminal.
+
+While **asciinema play <filename>** replays the recorded session using timing
+information saved in the asciicast, **asciinema cat <filename>** dumps the full
+output (including all escape sequences) to a terminal immediately.
+
+**asciinema cat existing.cast >output.txt** gives the same result as recording via
+**asciinema rec \-\-raw output.txt**.
+
+
+upload <_filename_>
+------
+
+Upload recorded asciicast to asciinema.org site.
+
+This command uploads given asciicast (recorded by **rec** command) to
+asciinema.org, where it can be watched and shared.
+
+**asciinema rec demo.cast** + **asciinema play demo.cast** + **asciinema upload
+demo.cast** is a nice combo if you want to review an asciicast before
+publishing it on asciinema.org.
+
+auth
+----
+
+Link and manage your install ID with your asciinema.org user account.
+
+If you want to manage your recordings (change title/theme, delete) at
+asciinema.org you need to link your "install ID" with your asciinema.org user
+account.
+
+This command displays the URL to open in a web browser to do that. 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
+**$HOME/.config/asciinema/install-id**. It'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.
+
+Note: A new install ID is generated on each machine and system user account you use
+asciinema on. So in order to keep all recordings under a single asciinema.org
+account you need to run **asciinema auth** on all of those machines. If you’re
+already logged in on asciinema.org website and you run 'asciinema auth' from a new
+computer then this new device will be linked to your account.
+
+While you CAN synchronize your config file (which keeps the API token) across
+all your machines so all use the same token, that’s not necessary. You can assign
+new tokens to your account from as many machines as you want.
+
+Note: asciinema versions prior to 2.0 confusingly referred to install ID as "API
+token".
+
+
+EXAMPLES
+========
+
+Record your first session:
+
+ asciinema rec first.cast
+
+End your session:
+
+ exit
+
+Now replay it with double speed:
+
+ asciinema play -s 2 first.cast
+
+Or with normal speed but with idle time limited to 2 seconds:
+
+ 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
+watch, try it.
+
+If you want to watch and share it on the web, upload it:
+
+ asciinema upload first.cast
+
+The above uploads it to <https://asciinema.org>, which is a
+default 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.
+
+You can record and upload in one step by omitting the filename:
+
+ asciinema rec
+
+You'll be asked to confirm the upload when the recording is done, so nothing is
+sent anywhere without your consent.
+
+
+Tricks
+------
+
+Record slowly, play faster:
+
+: First record a session where you can take your time to type slowly what you want
+ to show in the recording:
+
+ asciinema rec initial.cast
+
+ Then record the replay of 'initial.cast' as 'final.cast', but with five times the
+ initially recorded speed, with all pauses capped to two seconds and with a title
+ set as "My fancy title"::
+
+ asciinema rec -c "asciinema play -s 5 -i 2 initial.cast" -t "My fancy title" final.cast
+
+Play from *`stdin`*:
+
+: &nbsp;
+
+ cat /path/to/asciicast.json | asciinema play -
+
+Play file from remote host accessible with SSH:
+
+: &nbsp;
+
+ ssh user@host cat /path/to/asciicat.json | asciinema play -
+
+
+ENVIRONMENT
+===========
+
+**ASCIINEMA_API_URL**
+
+: This variable allows overriding asciinema-server URL (which defaults to
+ https://asciinema.org) in case you're running your own asciinema-server instance.
+
+**ASCIINEMA_CONFIG_HOME**
+
+: This variable allows overriding config directory location. Default location
+ is $XDG\_CONFIG\_HOME/asciinema (when $XDG\_CONFIG\_HOME is set)
+ or $HOME/.config/asciinema.
+
+
+BUGS
+====
+
+See GitHub Issues: <https://github.com/asciinema/asciinema/issues>
+
+
+MORE RESOURCES
+===============
+
+More documentation is available on the asciicast.org website and its GitHub wiki:
+
+* Web: [asciinema.org/docs/](https://asciinema.org/docs/)
+* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki)
+<<<<<<< HEAD
+=======
+* IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema)
+>>>>>>> develop
+* Twitter: [@asciinema](https://twitter.com/asciinema)
+
+
+AUTHORS
+=======
+
+asciinema's lead developer is Marcin Kulik.
+
+For a list of all contributors look here: <https://github.com/asciinema/asciinema/contributors>
+
+This Manual Page was written by Marcin Kulik with help from Kurt Pfeifle.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..cd9ced2
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 79
+target-version = ["py38"]
+
+[tool.isort]
+line_length = 79
+profile = "black"
+multi_line_output = 3
+
+[tool.mypy]
+check_untyped_defs = true
+disallow_any_generics = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+no_implicit_optional = true
+no_implicit_reexport = true
+show_error_context = true
+warn_redundant_casts = true
+warn_return_any = true
+warn_unused_configs = true
+warn_unused_ignores = true
+exclude = []
+
+[tool.pylint."MESSAGES CONTROL"]
+disable = [
+ "invalid-name",
+ "missing-class-docstring",
+ "missing-function-docstring",
+ "missing-module-docstring",
+]
+min-similarity-lines = 7
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..7e75a81
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,59 @@
+[metadata]
+name = asciinema
+version = 2.2.0
+author = Marcin Kulik
+author_email = m@ku1ik.com
+url = https://asciinema.org
+download_url =
+ https://github.com/asciinema/asciinema/archive/v%(version)s.tar.gz
+description = Terminal session recorder
+description_file = README.md
+license = GNU GPLv3
+license_file = LICENSE
+long_description = file: README.md
+long_description_content_type = text/markdown; charset=UTF-8
+classifiers =
+ Development Status :: 5 - Production/Stable
+ Environment :: Console
+ Intended Audience :: Developers
+ Intended Audience :: System Administrators
+ License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
+ Natural Language :: English
+ Programming Language :: Python
+ Programming Language :: Python :: 3.6
+ Programming Language :: Python :: 3.7
+ Programming Language :: Python :: 3.8
+ Programming Language :: Python :: 3.9
+ Programming Language :: Python :: 3.10
+ Topic :: System :: Shells
+ Topic :: Terminals
+ Topic :: Utilities
+
+[options]
+include_package_data = True
+packages =
+ asciinema
+ asciinema.asciicast
+ asciinema.commands
+install_requires =
+
+[options.package_data]
+asciinema = data/*.png
+
+[options.entry_points]
+console_scripts =
+ asciinema = asciinema.__main__:main
+
+[options.data_files]
+share/doc/asciinema =
+ CHANGELOG.md
+ CODE_OF_CONDUCT.md
+ CONTRIBUTING.md
+ README.md
+ doc/asciicast-v1.md
+ doc/asciicast-v2.md
+share/man/man1 =
+ man/asciinema.1
+
+[pycodestyle]
+ignore = E501,E402,E722
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/asciicast/__init__.py b/tests/asciicast/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/asciicast/__init__.py
diff --git a/tests/asciicast/v2_test.py b/tests/asciicast/v2_test.py
new file mode 100644
index 0000000..113ddf7
--- /dev/null
+++ b/tests/asciicast/v2_test.py
@@ -0,0 +1,28 @@
+import json
+import tempfile
+
+from asciinema.asciicast import v2
+
+from ..test_helper import Test
+
+
+class TestWriter(Test):
+ @staticmethod
+ def test_writing() -> None:
+ _file, path = tempfile.mkstemp()
+
+ with v2.writer(path, width=80, height=24) as w:
+ w.write_stdout(1, "x") # ensure it supports both str and bytes
+ w.write_stdout(2, bytes.fromhex("78 c5 bc c3 b3 c5"))
+ w.write_stdout(3, bytes.fromhex("82 c4 87"))
+ w.write_stdout(4, bytes.fromhex("78 78"))
+
+ with open(path, "rt", encoding="utf_8") as f:
+ lines = list(map(json.loads, f.read().strip().split("\n")))
+ assert lines == [
+ {"version": 2, "width": 80, "height": 24},
+ [1, "o", "x"],
+ [2, "o", "xżó"],
+ [3, "o", "łć"],
+ [4, "o", "xx"],
+ ], f"got:\n\n{lines}"
diff --git a/tests/config_test.py b/tests/config_test.py
new file mode 100644
index 0000000..7b154ff
--- /dev/null
+++ b/tests/config_test.py
@@ -0,0 +1,218 @@
+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: Optional[str] = None, env: Optional[Dict[str, str]] = None
+) -> Config:
+ # avoid redefining `dir` builtin
+ dir_ = tempfile.mkdtemp()
+
+ if content:
+ # 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)
+
+
+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() -> None:
+ config = create_config()
+ config.upgrade()
+ install_id = read_install_id(config.install_id_path)
+
+ assert re.match("^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}", install_id)
+ assert install_id == config.install_id
+ assert not path.exists(config.config_file_path)
+
+ # it must not change after another upgrade
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == install_id
+
+
+def test_upgrade_config_file_with_api_token() -> None:
+ config = create_config("[api]\ntoken = foo-bar-baz")
+ config.upgrade()
+
+ 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 read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_api_token_and_more() -> None:
+ config = create_config(
+ "[api]\ntoken = foo-bar-baz\nurl = http://example.com"
+ )
+ config.upgrade()
+
+ assert 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 read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_user_token() -> None:
+ config = create_config("[user]\ntoken = foo-bar-baz")
+ config.upgrade()
+
+ 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 read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_user_token_and_more() -> None:
+ config = create_config(
+ "[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com"
+ )
+ config.upgrade()
+
+ assert 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 read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_default_api_url() -> None:
+ config = create_config("")
+ assert config.api_url == "https://asciinema.org"
+
+
+def test_default_record_stdin() -> None:
+ config = create_config("")
+ assert config.record_stdin is False
+
+
+def test_default_record_command() -> None:
+ config = create_config("")
+ assert config.record_command is None
+
+
+def test_default_record_env() -> None:
+ config = create_config("")
+ assert config.record_env == "SHELL,TERM"
+
+
+def test_default_record_idle_time_limit() -> None:
+ config = create_config("")
+ assert config.record_idle_time_limit is None
+
+
+def test_default_record_yes() -> None:
+ config = create_config("")
+ assert config.record_yes is False
+
+
+def test_default_record_quiet() -> None:
+ config = create_config("")
+ assert config.record_quiet is False
+
+
+def test_default_play_idle_time_limit() -> None:
+ config = create_config("")
+ assert config.play_idle_time_limit is None
+
+
+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() -> 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() -> None:
+ command = "bash -l"
+ config = create_config(f"[record]\ncommand = {command}")
+ assert config.record_command == command
+
+
+def test_record_stdin() -> None:
+ config = create_config("[record]\nstdin = yes")
+ assert config.record_stdin is True
+
+
+def test_record_env() -> None:
+ config = create_config("[record]\nenv = FOO,BAR")
+ assert config.record_env == "FOO,BAR"
+
+
+def test_record_idle_time_limit() -> None:
+ config = create_config("[record]\nidle_time_limit = 2.35")
+ assert config.record_idle_time_limit == 2.35
+
+ config = create_config("[record]\nmaxwait = 2.35")
+ assert config.record_idle_time_limit == 2.35
+
+
+def test_record_yes() -> None:
+ yes = "yes"
+ config = create_config(f"[record]\nyes = {yes}")
+ assert config.record_yes is True
+
+
+def test_record_quiet() -> None:
+ quiet = "yes"
+ config = create_config(f"[record]\nquiet = {quiet}")
+ assert config.record_quiet is True
+
+
+def test_play_idle_time_limit() -> None:
+ config = create_config("[play]\nidle_time_limit = 2.35")
+ assert config.play_idle_time_limit == 2.35
+
+ config = create_config("[play]\nmaxwait = 2.35")
+ assert config.play_idle_time_limit == 2.35
+
+
+def test_notifications_enabled() -> None:
+ config = create_config("")
+ assert config.notifications_enabled is True
+
+ config = create_config("[notifications]\nenabled = yes")
+ assert config.notifications_enabled is True
+
+ config = create_config("[notifications]\nenabled = no")
+ assert config.notifications_enabled is False
+
+
+def test_notifications_command() -> None:
+ config = create_config("")
+ assert config.notifications_command is None
+
+ config = create_config(
+ '[notifications]\ncommand = tmux display-message "$TEXT"'
+ )
+ assert config.notifications_command == 'tmux display-message "$TEXT"'
diff --git a/tests/demo.cast b/tests/demo.cast
new file mode 100644
index 0000000..fe55360
--- /dev/null
+++ b/tests/demo.cast
@@ -0,0 +1,40 @@
+{"env": {"TERM": "xterm-256color", "SHELL": "/usr/local/bin/fish"}, "width": 75, "height": 18, "timestamp": 1509091818, "version": 2, "idle_time_limit": 2.0}
+[0.089436, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[0.100989, "o", "\u001b[?2004h"]
+[0.164215, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[0.164513, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K"]
+[0.164709, "o", "\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"]
+[1.511526, "i", "v"]
+[1.511937, "o", "v"]
+[1.512148, "o", "\b\u001b[38;2;0;95;215mv\u001b[30m\u001b(B\u001b[m"]
+[1.514564, "o", "\u001b[38;2;85;85;85mim tests/vim.cast \u001b[18D\u001b[30m\u001b(B\u001b[m"]
+[1.615727, "i", "i"]
+[1.616261, "o", "\u001b[38;2;0;95;215mi\u001b[38;2;85;85;85mm tests/vim.cast \u001b[17D\u001b[30m\u001b(B\u001b[m"]
+[1.694908, "i", "m"]
+[1.695262, "o", "\u001b[38;2;0;95;215mm\u001b[38;2;85;85;85m tests/vim.cast \u001b[16D\u001b[30m\u001b(B\u001b[m"]
+[2.751713, "i", "\r"]
+[2.752186, "o", "\u001b[K\r\n\u001b[30m"]
+[2.752381, "o", "\u001b(B\u001b[m\u001b[?2004l"]
+[2.752718, "o", "\u001b]0;vim /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m\r"]
+[2.86619, "o", "\u001b[?1000h\u001b[?2004h\u001b[?1049h\u001b[?1h\u001b=\u001b[?2004h"]
+[2.867669, "o", "\u001b[1;18r\u001b[?12h\u001b[?12l\u001b[27m\u001b[29m\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[H\u001b[2J\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H\u001b[>c"]
+[2.868169, "i", "\u001b[2;2R\u001b[>0;95;0c"]
+[2.869918, "o", "\u001b[?1000l\u001b[?1002h\u001b[?12$p"]
+[2.870136, "o", "\u001b[?25l\u001b[1;1H\u001b[93m1 \u001b[m\u001b[38;5;231m\u001b[48;5;235m\r\n\u001b[38;5;59m\u001b[48;5;236m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "]
+[2.870245, "o", " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[17;1H\u001b[1m\u001b[38;5;231m\u001b[48;5;236m[No Name] (unix/utf-8/) (line 0/1, col 000)\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[3;30HVIM - Vi IMproved\u001b[5;30Hversion 8.0.1171\u001b[6;26Hby Bram Moolenaar et al.\u001b[7;17HVim is open source and freely distributable\u001b[9;24HBecome a registered Vim user!\u001b[10;15Htype :help register\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for information \u001b[12;15Htype :q\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m to exit \u001b[13;15Htype :help\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m or \u001b[38;5;59m\u001b[48;5;236m<F1>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for on-line help\u001b[14;15Htype :help version8\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for version"]
+[2.870302, "o", " info\u001b[1;5H\u001b[?25h"]
+[5.63147, "i", ":"]
+[5.631755, "o", "\u001b[?25l\u001b[18;65H:\u001b[1;5H"]
+[5.631934, "o", "\u001b[18;65H\u001b[K\u001b[18;1H:\u001b[?2004l\u001b[?2004h\u001b[?25h"]
+[6.16692, "i", "q"]
+[6.167137, "o", "q\u001b[?25l\u001b[?25h"]
+[7.463349, "i", "\r"]
+[7.463561, "o", "\r"]
+[7.498922, "o", "\u001b[?25l\u001b[?1002l\u001b[?2004l"]
+[7.604236, "o", "\u001b[18;1H\u001b[K\u001b[18;1H\u001b[?2004l\u001b[?1l\u001b>\u001b[?25h\u001b[?1049l"]
+[7.612576, "o", "\u001b[?2004h"]
+[7.655999, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[7.656239, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"]
+[11.891762, "i", "\u0004"]
+[11.893297, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[30m\u001b(B\u001b[m"]
+[11.89348, "o", "\u001b[?2004l"]
diff --git a/tests/demo.json b/tests/demo.json
new file mode 100644
index 0000000..68092ae
--- /dev/null
+++ b/tests/demo.json
@@ -0,0 +1,114 @@
+{
+ "version": 1,
+ "width": 80,
+ "height": 40,
+ "duration": 6.46111,
+ "command": "/bin/bash",
+ "title": null,
+ "env": {
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/bash"
+ },
+ "stdout": [
+ [
+ 0.013659,
+ "\u001b[?1034hbash-3.2$ "
+ ],
+ [
+ 1.923187,
+ "v"
+ ],
+ [
+ 0.064049,
+ "i"
+ ],
+ [
+ 0.032034,
+ "m"
+ ],
+ [
+ 0.19157,
+ "\r\n"
+ ],
+ [
+ 0.032342,
+ "\u001b[?1049h\u001b[?1h\u001b=\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H"
+ ],
+ [
+ 0.001436,
+ "\u001b[1;40r\u001b[?12;25h\u001b[?12l\u001b[?25h\u001b[27m\u001b[m\u001b[H\u001b[2J\u001b[>c"
+ ],
+ [
+ 0.000311,
+ "\u001b[?25l\u001b[1;1H\u001b[33m 1 \u001b[m\r\n\u001b[1m\u001b[34m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "
+ ],
+ [
+ 3.9e-05,
+ " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[17;1H~ \u001b[18;1H~ \u001b[19;1H~ \u001b[20;1H~ \u001b[21;1H~ \u001b[22;1H~ \u001b[23;1H~ \u001b[24;1H~ \u001b[25;1H~ "
+ ],
+ [
+ 9.2e-05,
+ " \u001b[26;1H~ \u001b[27;1H~ \u001b[28;1H~ \u001b[29;1H~ \u001b[30;1H~ \u001b[31;1H~ \u001b[32;1H~ \u001b[33;1H~ \u001b[34;1H~ \u001b[35;1H~ \u001b[36;1H~ \u001b[37;"
+ ],
+ [
+ 2.4e-05,
+ "1H~ \u001b[38;1H~ \u001b[m\u001b[39;1H\u001b[1m\u001b[7m[No Name] \u001b[m\u001b[14;32HVIM - Vi IMproved\u001b[16;33Hversion 7.4.8056\u001b[17;29Hby Bram Moolenaar et al.\u001b[18;19HVim is open source and freely distributable\u001b[20;26HBecome a registered Vim user!\u001b[21;18Htype :help register\u001b[32m<Enter>\u001b[m for information \u001b[23;18Htype :q\u001b[32m<Enter>\u001b[m to exit \u001b[24;18Htype :help\u001b[32m<Enter>\u001b[m or \u001b[32m<F1>\u001b[m for on-line help\u001b[25;18Htype :help version7\u001b[32m<Enter>\u001b[m for version info\u001b[1;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 1.070242,
+ "\u001b[?25l\u001b[40;1H:"
+ ],
+ [
+ 2.3e-05,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.503964,
+ "q"
+ ],
+ [
+ 0.151903,
+ "u"
+ ],
+ [
+ 0.04002,
+ "i"
+ ],
+ [
+ 0.088084,
+ "t"
+ ],
+ [
+ 0.287636,
+ "\r"
+ ],
+ [
+ 0.002178,
+ "\u001b[?25l\u001b[40;1H\u001b[K\u001b[40;1H\u001b[?1l\u001b>\u001b[?12l\u001b[?25h\u001b[?1049l"
+ ],
+ [
+ 0.000999,
+ "bash-3.2$ "
+ ],
+ [
+ 1.58912,
+ "e"
+ ],
+ [
+ 0.184114,
+ "x"
+ ],
+ [
+ 0.087915,
+ "i"
+ ],
+ [
+ 0.103987,
+ "t"
+ ],
+ [
+ 0.087613,
+ "\r\n"
+ ]
+ ]
+}
diff --git a/tests/distros.sh b/tests/distros.sh
new file mode 100755
index 0000000..c34d272
--- /dev/null
+++ b/tests/distros.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+
+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}"
+
+ # shellcheck disable=SC2068
+ "${DOCKER}" build ${docker_opts[@]} .
+
+ "${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh
+}
+
+
+for distro in "${DISTROS[@]}"; do
+ test_ "${distro}"
+done
+
+printf "\n\e[1;32mAll tests passed.\e[0m\n"
diff --git a/tests/distros/Dockerfile.alpine b/tests/distros/Dockerfile.alpine
new file mode 100644
index 0000000..9716325
--- /dev/null
+++ b/tests/distros/Dockerfile.alpine
@@ -0,0 +1,19 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/alpine:3.15
+
+# https://github.com/actions/runner/issues/241
+RUN apk --no-cache add bash ca-certificates make python3 util-linux
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.arch b/tests/distros/Dockerfile.arch
new file mode 100644
index 0000000..3224495
--- /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 make python3 \
+ && printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \
+ && locale-gen \
+ && pacman --sync --clean --clean --noconfirm
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos
new file mode 100644
index 0000000..bc4fd7e
--- /dev/null
+++ b/tests/distros/Dockerfile.centos
@@ -0,0 +1,18 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/centos:7
+
+RUN yum install -y epel-release && yum install -y make python36 && yum clean all
+
+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.debian b/tests/distros/Dockerfile.debian
new file mode 100644
index 0000000..6c14287
--- /dev/null
+++ b/tests/distros/Dockerfile.debian
@@ -0,0 +1,33 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/debian:bullseye
+
+ENV DEBIAN_FRONTENT="noninteractive"
+
+RUN apt-get update \
+ && apt-get install -y \
+ ca-certificates \
+ locales \
+ make \
+ procps \
+ python3 \
+ && localedef \
+ -i en_US \
+ -c \
+ -f UTF-8 \
+ -A /usr/share/locale/locale.alias \
+ en_US.UTF-8 \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src/app
+
+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
new file mode 100644
index 0000000..e5abb51
--- /dev/null
+++ b/tests/distros/Dockerfile.fedora
@@ -0,0 +1,20 @@
+# syntax=docker/dockerfile:1.3
+
+# https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921
+# https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html
+FROM registry.fedoraproject.org/fedora:34
+
+RUN dnf install -y make python3 procps && dnf clean all
+
+WORKDIR /usr/src/app
+
+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
new file mode 100644
index 0000000..38223c2
--- /dev/null
+++ b/tests/distros/Dockerfile.ubuntu
@@ -0,0 +1,32 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/ubuntu:20.04
+
+ENV DEBIAN_FRONTENT="noninteractive"
+
+RUN apt-get update \
+ && apt-get install -y \
+ ca-certificates \
+ locales \
+ make \
+ python3 \
+ && localedef \
+ -i en_US \
+ -c \
+ -f UTF-8 \
+ -A /usr/share/locale/locale.alias \
+ en_US.UTF-8 \
+ && rm -rf /var/lib/apt/lists/*
+
+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/integration.sh b/tests/integration.sh
new file mode 100755
index 0000000..9f4f5d1
--- /dev/null
+++ b/tests/integration.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+
+set -eExuo pipefail
+
+if ! command -v "pkill" >/dev/null 2>&1; then
+ printf "error: pkill not installed\n"
+ exit 1
+fi
+
+python3 -V
+
+ASCIINEMA_CONFIG_HOME="$(
+ mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
+)"
+
+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
+
+asciinema -h
+
+## test version command
+
+asciinema --version
+
+## test auth command
+
+asciinema auth
+
+## test play command
+
+# 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"
+
+# very quickly exiting program
+asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast"
+grep '"o",' "${TMP_DATA_DIR}/1b.cast"
+
+# signal handling
+bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" &
+asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast"
+
+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"
+
+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
+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"
+
+# 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"
diff --git a/tests/pty_test.py b/tests/pty_test.py
new file mode 100644
index 0000000..0f309c7
--- /dev/null
+++ b/tests/pty_test.py
@@ -0,0 +1,54 @@
+import os
+import pty
+from typing import Any, List, Union
+
+import asciinema.pty_
+
+from .test_helper import Test
+
+
+class Writer:
+ def __init__(self) -> None:
+ self.data: List[Union[float, str]] = []
+
+ def write_stdout(self, _ts: float, data: Any) -> None:
+ self.data.append(data)
+
+ def write_stdin(self, ts: float, data: Any) -> None:
+ raise NotImplementedError
+
+
+class TestRecord(Test):
+ def setUp(self) -> None:
+ self.real_os_write = os.write
+ os.write = self.os_write # type: ignore
+
+ def tearDown(self) -> None:
+ os.write = self.real_os_write
+
+ def os_write(self, fd: int, data: Any) -> None:
+ if fd != pty.STDOUT_FILENO:
+ self.real_os_write(fd, data)
+
+ @staticmethod
+ def test_record_command_writes_to_stdout() -> None:
+ writer = Writer()
+
+ command = [
+ "python3",
+ "-c",
+ (
+ "import sys"
+ "; import time"
+ "; sys.stdout.write('foo')"
+ "; sys.stdout.flush()"
+ "; time.sleep(0.01)"
+ "; sys.stdout.write('bar')"
+ ),
+ ]
+
+ asciinema.pty_.record(
+ command, {}, writer, lambda: (80, 24), lambda s: None, {}
+ )
+
+ assert writer.data == [b"foo", b"bar"]
diff --git a/tests/test_helper.py b/tests/test_helper.py
new file mode 100644
index 0000000..03b7e97
--- /dev/null
+++ b/tests/test_helper.py
@@ -0,0 +1,16 @@
+import sys
+from codecs import StreamReader
+from io import StringIO
+from typing import Optional, TextIO, Union
+
+stdout: Optional[Union[TextIO, StreamReader]] = None
+
+
+class Test:
+ def setUp(self) -> None:
+ global stdout # pylint: disable=global-statement
+ self.real_stdout = sys.stdout
+ sys.stdout = stdout = StringIO()
+
+ def tearDown(self) -> None:
+ sys.stdout = self.real_stdout