mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-16 11:48:13 +01:00
38
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help improve asciinema
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
To make life of the project maintainers easier please submit bug reports only.
|
||||
|
||||
This is bug tracker for asciinema cli (aka recorder). If your issue seems to be with another component (js player, server) then open an issue in related repository. If you're experiencing issue with asciinema server running at asciinema.org then contact admin@asciinema.org.
|
||||
|
||||
Ideas, feature requests, help requests, questions and general discussions should be discussed in project's Discussions instead: https://github.com/orgs/asciinema/discussions
|
||||
|
||||
If you think you've found a bug or regression, go ahead and delete this message and fill in the details below.
|
||||
|
||||
-----
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Versions:**
|
||||
- OS: [e.g. macOS 12.6, Ubuntu 23.04]
|
||||
- asciinema cli: [e.g. 2.2.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
8
.github/workflows/asciinema.yml
vendored
8
.github/workflows/asciinema.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: checkout asciinema
|
||||
uses: actions/checkout@v3
|
||||
- name: setup Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: install dependencies
|
||||
@@ -33,18 +33,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
env:
|
||||
TERM: dumb
|
||||
steps:
|
||||
- name: checkout Asciinema
|
||||
uses: actions/checkout@v3
|
||||
- name: setup Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: install dependencies
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
username: "${{ github.actor }}"
|
||||
password: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: "Build ${{ matrix.distros }} image"
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: "tests/distros/Dockerfile.${{ matrix.distros }}"
|
||||
tags: |
|
||||
|
||||
4
.github/workflows/pre-commit.yml
vendored
4
.github/workflows/pre-commit.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- uses: pre-commit/action@v2.0.3
|
||||
- uses: pre-commit/action@v3.0.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.1.0
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
- id: check-json
|
||||
@@ -11,19 +11,19 @@ repos:
|
||||
- id: requirements-txt-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.10.1
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/adrienverge/yamllint
|
||||
rev: v1.26.3
|
||||
rev: v1.29.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v1.4
|
||||
rev: v2.0.2
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args:
|
||||
@@ -34,6 +34,6 @@ repos:
|
||||
- --remove-duplicate-keys
|
||||
- --remove-unused-variables
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: "v0.931"
|
||||
rev: "v1.1.1"
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,5 +1,25 @@
|
||||
# asciinema changelog
|
||||
|
||||
## 2.3.0 (2023-07-05)
|
||||
|
||||
* Added official support for Python 3.11
|
||||
* Dropped official support for Python 3.6
|
||||
* Implemented markers in `rec` and `play -m` commands
|
||||
* Added `--loop` option for looped playback in `play` command
|
||||
* Added `--stream` and `--out-fmt` option for customizing output of `play` command
|
||||
* Improved terminal charset detection (thanks @djds)
|
||||
* Extended `cat` command to support multiple files (thanks @Low-power)
|
||||
* Improved upload error messages
|
||||
* Fixed direct playback from URL
|
||||
* Made raw output start with terminal size sequence (`\e[8;H;Wt`)
|
||||
* Prevented recording to stdout when it's a TTY
|
||||
* Added target file permission checks to avoid ugly errors
|
||||
* Removed named pipe re-opening, which was causing hangs in certain scenarios
|
||||
* Improved PTY/TTY data reading - it goes in bigger chunks now (256 kb)
|
||||
* Fixed deadlock in PTY writes (thanks @Low-power)
|
||||
* Improved input forwarding from stdin
|
||||
* Ignored OSC responses in recorded stdin stream
|
||||
|
||||
## 2.2.0 (2022-05-07)
|
||||
|
||||
* Added official support for Python 3.8, 3.9, 3.10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/ubuntu:20.04
|
||||
FROM docker.io/library/ubuntu:22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
|
||||
73
README.md
73
README.md
@@ -133,18 +133,18 @@ 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
|
||||
22.04](https://releases.ubuntu.com/22.04/) and has the latest version of
|
||||
asciinema recorder pre-installed.
|
||||
|
||||
```sh
|
||||
docker pull docker.io/asciinema/asciinema
|
||||
docker pull ghcr.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
|
||||
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" ghcr.io/asciinema/asciinema rec
|
||||
```
|
||||
|
||||
Container's entrypoint is set to `/usr/local/bin/asciinema` so you can run the
|
||||
@@ -153,11 +153,13 @@ 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`:
|
||||
image from this one (start your custom Dockerfile with `FROM
|
||||
ghcr.io/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
|
||||
docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash ghcr.io/asciinema/asciinema rec
|
||||
root@6689517d99a1:~# apt-get install foobar
|
||||
root@6689517d99a1:~# asciinema rec
|
||||
```
|
||||
@@ -173,7 +175,7 @@ docker run --rm -it \
|
||||
--volume="${HOME}/.config/asciinema:/run/user/$(id -u)/.config/asciinema:rw" \
|
||||
--volume="${PWD}:/data:rw" \
|
||||
--workdir='/data' \
|
||||
docker.io/asciinema/asciinema rec
|
||||
ghcr.io/asciinema/asciinema rec
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -236,7 +238,7 @@ Available options:
|
||||
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
|
||||
pressed keys. Because it's basically key-logging (scoped to a single shell
|
||||
instance), it's disabled by default, and has to be explicitly enabled via
|
||||
`--stdin` option.
|
||||
|
||||
@@ -293,6 +295,21 @@ Available options:
|
||||
|
||||
- `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
|
||||
- `-s, --speed=<factor>` - Playback speed (can be fractional)
|
||||
- `-l, --loop` - Play in a loop
|
||||
- `-m, --pause-on-markers` - Automatically pause on [markers](#markers)
|
||||
- `--stream=<stream>` - Select stream to play (see below)
|
||||
- `--out-fmt=<format>` - Select output format (see below)
|
||||
|
||||
By default the output stream (`o`) is played. This is what you want in most
|
||||
cases. If you recorded the input stream (`i`) with `asciinema rec --stdin` then
|
||||
you can replay it with `asciinema play --stream=i <filename>`.
|
||||
|
||||
By default the selected stream is written to stdout in original, raw data form.
|
||||
This is also what you want in majority of cases. However you can change the
|
||||
output format to asciicast (newline delimited JSON) with `asciinema play
|
||||
--out-fmt=asciicast <filename>`. This allows delegating actual rendering to
|
||||
another place (e.g. outside of your terminal) by piping output of `asciinema
|
||||
play` to a tool of your choice.
|
||||
|
||||
> 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
|
||||
@@ -345,6 +362,29 @@ happen in any order.
|
||||
> asciinema versions prior to 2.0 confusingly referred to install ID as "API
|
||||
> token".
|
||||
|
||||
## Markers
|
||||
|
||||
Markers allow marking specific time locations in a recording, which can be used
|
||||
for navigation, as well as for automatic pausing of the playback.
|
||||
|
||||
Markers can be added to a recording in several ways:
|
||||
|
||||
- while you are recording, by pressing a configured hotkey, see [add_marker_key
|
||||
config option](#configuration-file)
|
||||
- for existing recording, by inserting marker events (`"m"`) in the asciicast
|
||||
file, see [marker event](doc/asciicast-v2.md#m---marker)
|
||||
|
||||
When replaying a recording with `asciinema play` you can enable
|
||||
auto-pause-on-marker behaviour with `-m`/`--pause-on-markers` option (it's off
|
||||
by default). When a marker is encountered, the playback automatically pauses and
|
||||
can be resumed by pressing space bar key. The playback continues until next
|
||||
marker is encountered. You can also fast-forward to the next marker by pressing
|
||||
`]` key (when paused).
|
||||
|
||||
Markers can be useful in e.g. live demos: you can create a recording with
|
||||
markers, then play it back during presentation, and have it stop wherever you
|
||||
want to explain terminal contents in more detail.
|
||||
|
||||
## Hosting the recordings on the web
|
||||
|
||||
As mentioned in the `Usage > rec` section above, if the `filename` argument to
|
||||
@@ -403,6 +443,9 @@ quiet = true
|
||||
; default: C-\ (control + backslash)
|
||||
pause_key = C-p
|
||||
|
||||
; Define hotkey for adding a marker, default: none
|
||||
add_marker_key = C-x
|
||||
|
||||
; Define hotkey prefix key - when defined other recording hotkeys must
|
||||
; be preceeded by it, default: no prefix
|
||||
prefix_key = C-a
|
||||
@@ -420,8 +463,12 @@ idle_time_limit = 1
|
||||
pause_key = p
|
||||
|
||||
; Define hotkey for stepping through playback, a frame at a time,
|
||||
; default: .
|
||||
step_key = ]
|
||||
; default: . (dot)
|
||||
step_key = s
|
||||
|
||||
; Define hotkey for jumping to the next marker,
|
||||
; default: ]
|
||||
next_marker_key = m
|
||||
|
||||
[notifications]
|
||||
; Desktop notifications are displayed on certain occasions, e.g. when
|
||||
@@ -455,6 +502,10 @@ If `$XDG_CONFIG_HOME` is set on Linux then asciinema uses
|
||||
> asciinema versions prior to 1.1 used `$HOME/.asciinema`. If you have it
|
||||
> there you should `mv $HOME/.asciinema $HOME/.config/asciinema`.
|
||||
|
||||
## Consulting
|
||||
|
||||
I offer consulting services for asciinema project. See https://asciinema.org/consulting for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute to this project check out
|
||||
@@ -467,7 +518,7 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2011–2021 Marcin Kulik.
|
||||
© 2011 Marcin Kulik.
|
||||
|
||||
All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file
|
||||
for details.
|
||||
|
||||
@@ -12,6 +12,21 @@ from .commands.record import RecordCommand
|
||||
from .commands.upload import UploadCommand
|
||||
|
||||
|
||||
def valid_encoding() -> bool:
|
||||
def _locales() -> Optional[str]:
|
||||
try:
|
||||
return locale.nl_langinfo(locale.CODESET)
|
||||
except AttributeError:
|
||||
return locale.getlocale()[-1]
|
||||
|
||||
loc = _locales()
|
||||
|
||||
if loc is None:
|
||||
return False
|
||||
else:
|
||||
return loc.upper() in ("US-ASCII", "UTF-8", "UTF8")
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
_value = int(value)
|
||||
if _value <= 0:
|
||||
@@ -35,11 +50,7 @@ def maybe_str(v: Any) -> Optional[str]:
|
||||
|
||||
|
||||
def main() -> Any:
|
||||
if locale.nl_langinfo(locale.CODESET).upper() not in [
|
||||
"US-ASCII",
|
||||
"UTF-8",
|
||||
"UTF8",
|
||||
]:
|
||||
if not valid_encoding():
|
||||
sys.stderr.write(
|
||||
"asciinema needs an ASCII or UTF-8 character encoding to run. "
|
||||
"Check the output of `locale` command.\n"
|
||||
@@ -174,10 +185,36 @@ For help on a specific command run:
|
||||
parser_play.add_argument(
|
||||
"-s",
|
||||
"--speed",
|
||||
help="playback speedup (can be fractional)",
|
||||
help="set playback speed (can be fractional)",
|
||||
type=positive_float,
|
||||
default=cfg.play_speed,
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"-l",
|
||||
"--loop",
|
||||
help="loop loop loop loop",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"-m",
|
||||
"--pause-on-markers",
|
||||
help="automatically pause on markers",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"--out-fmt",
|
||||
help="select output format",
|
||||
choices=["raw", "asciicast"],
|
||||
default="raw",
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"--stream",
|
||||
help="select stream to play",
|
||||
choices=["o", "i"],
|
||||
default=None,
|
||||
)
|
||||
parser_play.add_argument(
|
||||
"filename", help='local path, http/ipfs URL or "-" (read from stdin)'
|
||||
)
|
||||
@@ -185,10 +222,12 @@ For help on a specific command run:
|
||||
|
||||
# create the parser for the `cat` command
|
||||
parser_cat = subparsers.add_parser(
|
||||
"cat", help="Print full output of terminal session"
|
||||
"cat", help="Print full output of terminal sessions"
|
||||
)
|
||||
parser_cat.add_argument(
|
||||
"filename", help='local path, http/ipfs URL or "-" (read from stdin)'
|
||||
"filename",
|
||||
nargs="+",
|
||||
help='local path, http/ipfs URL or "-" (read from stdin)',
|
||||
)
|
||||
parser_cat.set_defaults(cmd=CatCommand)
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ from .urllib_http_adapter import URLLibHttpAdapter
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
pass
|
||||
def __init__(self, e: str, retryable: bool):
|
||||
super().__init__(e)
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
class Api:
|
||||
@@ -48,7 +50,7 @@ class Api:
|
||||
password=self.install_id,
|
||||
)
|
||||
except HTTPConnectionError as e:
|
||||
raise APIError(str(e)) from e
|
||||
raise APIError(str(e), True) from e
|
||||
|
||||
if status not in (200, 201):
|
||||
self._handle_error(status, body)
|
||||
@@ -82,9 +84,9 @@ class Api:
|
||||
"This asciinema version may no longer be supported. "
|
||||
"Please upgrade to the latest version."
|
||||
),
|
||||
413: "Sorry, your asciicast is too big.",
|
||||
413: "Sorry, the size of your recording exceeds the server-configured limit.",
|
||||
422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
|
||||
503: "The server is down for maintenance. Try again in a minute.",
|
||||
503: "The server is down for maintenance.",
|
||||
}
|
||||
|
||||
error = errors.get(status)
|
||||
@@ -98,4 +100,4 @@ class Api:
|
||||
else:
|
||||
error = f"HTTP status: {status}"
|
||||
|
||||
raise APIError(error)
|
||||
raise APIError(error, status >= 500)
|
||||
|
||||
@@ -5,6 +5,7 @@ import sys
|
||||
import urllib.error
|
||||
from codecs import StreamReader
|
||||
from html.parser import HTMLParser
|
||||
from io import BytesIO
|
||||
from typing import Any, List, TextIO, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import Request, urlopen
|
||||
@@ -58,15 +59,19 @@ def open_url(url: str) -> Union[StreamReader, TextIO]:
|
||||
if url.startswith("http:") or url.startswith("https:"):
|
||||
req = Request(url)
|
||||
req.add_header("Accept-Encoding", "gzip")
|
||||
body = None
|
||||
content_type = None
|
||||
utf8_reader = codecs.getreader("utf-8")
|
||||
|
||||
with urlopen(req) as response:
|
||||
body = response
|
||||
content_type = response.headers["Content-Type"]
|
||||
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"]
|
||||
body = BytesIO(body.read())
|
||||
|
||||
if content_type and content_type.startswith("text/html"):
|
||||
html = utf8_reader(body, errors="replace").read()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Generator, List, Optional
|
||||
from typing import Any, Generator, Iterable, List, Optional
|
||||
|
||||
|
||||
def to_relative_time(
|
||||
events: Generator[List[Any], None, None]
|
||||
events: Iterable[Any],
|
||||
) -> Generator[List[Any], None, None]:
|
||||
prev_time = 0
|
||||
|
||||
@@ -14,7 +14,7 @@ def to_relative_time(
|
||||
|
||||
|
||||
def to_absolute_time(
|
||||
events: Generator[List[Any], None, None]
|
||||
events: Iterable[Any],
|
||||
) -> Generator[List[Any], None, None]:
|
||||
time = 0
|
||||
|
||||
@@ -25,8 +25,8 @@ def to_absolute_time(
|
||||
|
||||
|
||||
def cap_relative_time(
|
||||
events: Generator[List[Any], None, None], time_limit: Optional[float]
|
||||
) -> Generator[List[Any], None, None]:
|
||||
events: Iterable[Any], time_limit: Optional[float]
|
||||
) -> Iterable[Any]:
|
||||
if time_limit:
|
||||
return (
|
||||
[min(delay, time_limit), type_, data]
|
||||
@@ -36,6 +36,6 @@ def cap_relative_time(
|
||||
|
||||
|
||||
def adjust_speed(
|
||||
events: Generator[List[Any], None, None], speed: Any
|
||||
events: Iterable[Any], speed: Any
|
||||
) -> Generator[List[Any], None, None]:
|
||||
return ([delay / speed, type_, data] for delay, type_, data in events)
|
||||
|
||||
@@ -23,8 +23,23 @@ class writer(file_writer):
|
||||
append = False
|
||||
|
||||
self.buffering = buffering
|
||||
self.mode: str = "ab" if append else "wb"
|
||||
self.metadata = metadata
|
||||
|
||||
if append:
|
||||
self.mode = "ab"
|
||||
self.header = None
|
||||
else:
|
||||
self.mode = "wb"
|
||||
width = metadata["width"]
|
||||
height = metadata["height"]
|
||||
self.header = f"\x1b[8;{height};{width}t".encode("utf-8")
|
||||
|
||||
def __enter__(self) -> Any:
|
||||
super().__enter__()
|
||||
|
||||
if self.header:
|
||||
self._write(self.header)
|
||||
|
||||
return self
|
||||
|
||||
def write_stdout(self, _ts: float, data: Any) -> None:
|
||||
self._write(data)
|
||||
@@ -33,6 +48,9 @@ class writer(file_writer):
|
||||
def write_stdin(self, ts: float, data: Any) -> None:
|
||||
pass
|
||||
|
||||
def write_marker(self, ts: float) -> None:
|
||||
pass
|
||||
|
||||
# pylint: disable=consider-using-with
|
||||
def _open_file(self) -> None:
|
||||
if self.path == "-":
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import json
|
||||
from codecs import StreamReader
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Any, Dict, Generator, List, Optional, TextIO, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Generator,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
TextIO,
|
||||
Union,
|
||||
)
|
||||
|
||||
from .events import to_absolute_time
|
||||
|
||||
@@ -26,16 +35,16 @@ class Asciicast:
|
||||
}
|
||||
return header
|
||||
|
||||
def events(self, type_: Optional[str] = None) -> Iterable[List[Any]]:
|
||||
if type_ in [None, "o"]:
|
||||
return to_absolute_time(self.__stdout_events())
|
||||
else:
|
||||
return []
|
||||
|
||||
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"
|
||||
|
||||
@@ -31,14 +31,17 @@ class Asciicast:
|
||||
self.v2_header = header
|
||||
self.idle_time_limit = header.get("idle_time_limit")
|
||||
|
||||
def events(self) -> Generator[Any, None, None]:
|
||||
def events(
|
||||
self, type_: Optional[str] = None
|
||||
) -> Generator[List[Any], None, None]:
|
||||
if type_ is 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]
|
||||
else:
|
||||
for line in self.__file:
|
||||
event = json.loads(line)
|
||||
if event[1] == type_:
|
||||
yield event
|
||||
|
||||
|
||||
def build_from_header_and_file(
|
||||
@@ -76,7 +79,7 @@ def get_duration(path_: str) -> Any:
|
||||
first_line = f.readline()
|
||||
with open_from_file(first_line, f) as a:
|
||||
last_frame = None
|
||||
for last_frame in a.stdout_events():
|
||||
for last_frame in a.events("o"):
|
||||
pass
|
||||
return last_frame[0]
|
||||
|
||||
@@ -142,6 +145,9 @@ class writer(file_writer):
|
||||
data = self.stdin_decoder.decode(data)
|
||||
self.__write_event(ts, "i", data)
|
||||
|
||||
def write_marker(self, ts: float) -> None:
|
||||
self.__write_event(ts, "m", "")
|
||||
|
||||
# pylint: disable=consider-using-with
|
||||
def _open_file(self) -> None:
|
||||
if self.path == "-":
|
||||
|
||||
@@ -20,10 +20,16 @@ class async_worker:
|
||||
self.process: Optional[Process] = None
|
||||
|
||||
def __enter__(self) -> Any:
|
||||
self.process = Process(target=self.run)
|
||||
self.process = Process(target=self._run)
|
||||
self.process.start()
|
||||
return self
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
self.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
def __exit__(
|
||||
self, exc_type: str, exc_value: str, exc_traceback: str
|
||||
) -> None:
|
||||
|
||||
@@ -10,14 +10,21 @@ 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
|
||||
self.filenames = 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():
|
||||
return self.cat()
|
||||
except OSError:
|
||||
return self.cat()
|
||||
|
||||
def cat(self) -> int:
|
||||
try:
|
||||
for filename in self.filenames:
|
||||
with asciicast.open_from_url(filename) as a:
|
||||
for _, _type, text in a.events("o"):
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class Command:
|
||||
def print(
|
||||
self,
|
||||
text: str,
|
||||
end: str = "\n",
|
||||
end: str = "\r\n",
|
||||
color: Optional[int] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
|
||||
@@ -18,17 +18,37 @@ class PlayCommand(Command):
|
||||
self.filename = args.filename
|
||||
self.idle_time_limit = args.idle_time_limit
|
||||
self.speed = args.speed
|
||||
self.loop = args.loop
|
||||
self.out_fmt = args.out_fmt
|
||||
self.stream = args.stream
|
||||
self.pause_on_markers = args.pause_on_markers
|
||||
self.player = player if player is not None else Player()
|
||||
self.key_bindings = {
|
||||
"pause": config.play_pause_key,
|
||||
"step": config.play_step_key,
|
||||
"next_marker": config.play_next_marker_key,
|
||||
}
|
||||
|
||||
def execute(self) -> int:
|
||||
code = self.play()
|
||||
|
||||
if self.loop:
|
||||
while code == 0:
|
||||
code = self.play()
|
||||
|
||||
return code
|
||||
|
||||
def play(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
|
||||
a,
|
||||
idle_time_limit=self.idle_time_limit,
|
||||
speed=self.speed,
|
||||
key_bindings=self.key_bindings,
|
||||
out_fmt=self.out_fmt,
|
||||
stream=self.stream,
|
||||
pause_on_markers=self.pause_on_markers,
|
||||
)
|
||||
|
||||
except asciicast.LoadError as e:
|
||||
|
||||
@@ -34,6 +34,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
self.key_bindings = {
|
||||
"prefix": config.record_prefix_key,
|
||||
"pause": config.record_pause_key,
|
||||
"add_marker": config.record_add_marker_key,
|
||||
}
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
@@ -53,6 +54,12 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
upload = True
|
||||
|
||||
if self.filename == "-":
|
||||
if sys.stdout.isatty():
|
||||
self.print_error(
|
||||
f"when recording to stdout it must not be TTY - forgot to pipe?"
|
||||
)
|
||||
return 1
|
||||
|
||||
append = False
|
||||
|
||||
elif os.path.exists(self.filename):
|
||||
@@ -76,7 +83,18 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
)
|
||||
return 1
|
||||
|
||||
elif append:
|
||||
else:
|
||||
dir_path = os.path.dirname(os.path.abspath(self.filename))
|
||||
|
||||
if not os.path.exists(dir_path):
|
||||
self.print_error(f"directory {dir_path} doesn't exist")
|
||||
return 1
|
||||
|
||||
if not os.access(dir_path, os.W_OK):
|
||||
self.print_error(f"directory {dir_path} is not writable")
|
||||
return 1
|
||||
|
||||
if append:
|
||||
self.print_warning(
|
||||
f"{self.filename} does not exist, not appending"
|
||||
)
|
||||
@@ -84,6 +102,9 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
|
||||
if append:
|
||||
self.print_info(f"appending to asciicast at {self.filename}")
|
||||
else:
|
||||
if self.filename == "-":
|
||||
self.print_info(f"recording asciicast to stdout")
|
||||
else:
|
||||
self.print_info(f"recording asciicast to {self.filename}")
|
||||
|
||||
@@ -118,6 +139,9 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
cols_override=self.cols_override,
|
||||
rows_override=self.rows_override,
|
||||
)
|
||||
except IOError as e:
|
||||
self.print_error(f"I/O error: {str(e)}")
|
||||
return 1
|
||||
except v2.LoadError:
|
||||
self.print_error(
|
||||
"can only append to asciicast v2 format recordings"
|
||||
@@ -155,7 +179,7 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
|
||||
f"retry later by running: asciinema upload {self.filename}"
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
elif self.filename != "-":
|
||||
self.print_info(f"asciicast saved to {self.filename}")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -25,9 +25,12 @@ class UploadCommand(Command):
|
||||
|
||||
except APIError as e:
|
||||
self.print_error(f"upload failed: {str(e)}")
|
||||
|
||||
if e.retryable:
|
||||
self.print_error(
|
||||
f"retry later by running: asciinema upload {self.filename}"
|
||||
)
|
||||
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@@ -136,6 +136,10 @@ class Config:
|
||||
def record_pause_key(self) -> Any:
|
||||
return self.__get_key("record", "pause", "C-\\")
|
||||
|
||||
@property
|
||||
def record_add_marker_key(self) -> Any:
|
||||
return self.__get_key("record", "add_marker")
|
||||
|
||||
@property
|
||||
def play_idle_time_limit(self) -> Optional[float]:
|
||||
fallback = self.config.getfloat(
|
||||
@@ -157,6 +161,10 @@ class Config:
|
||||
def play_step_key(self) -> Any:
|
||||
return self.__get_key("play", "step", ".")
|
||||
|
||||
@property
|
||||
def play_next_marker_key(self) -> Any:
|
||||
return self.__get_key("play", "next_marker", "]")
|
||||
|
||||
@property
|
||||
def notifications_enabled(self) -> bool:
|
||||
return self.config.getboolean(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
import stat
|
||||
from typing import IO, Any, Callable, Optional
|
||||
|
||||
|
||||
@@ -29,14 +27,8 @@ class file_writer:
|
||||
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")
|
||||
except IOError as e:
|
||||
self.on_error("Write error, recording suspended")
|
||||
raise e
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional, TextIO, Union
|
||||
@@ -7,6 +8,44 @@ from .asciicast.v1 import Asciicast as v1
|
||||
from .asciicast.v2 import Asciicast as v2
|
||||
from .tty_ import raw, read_blocking
|
||||
|
||||
Header = Dict[str, Any]
|
||||
|
||||
|
||||
class RawOutput:
|
||||
def __init__(self, stream: Optional[str]) -> None:
|
||||
self.stream = stream or "o"
|
||||
|
||||
def start(self, _header: Header) -> None:
|
||||
pass
|
||||
|
||||
def write(self, _time: float, event_type: str, data: str) -> None:
|
||||
if event_type == self.stream:
|
||||
sys.stdout.write(data)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class AsciicastOutput:
|
||||
def __init__(self, stream: Optional[str]) -> None:
|
||||
self.stream = stream
|
||||
|
||||
def start(self, header: Header) -> None:
|
||||
self.__write_line(header)
|
||||
|
||||
def write(self, time: float, event_type: str, data: str) -> None:
|
||||
if self.stream in [None, event_type]:
|
||||
self.__write_line([time, event_type, data])
|
||||
|
||||
def __write_line(self, obj: Any) -> None:
|
||||
line = json.dumps(
|
||||
obj, ensure_ascii=False, indent=None, separators=(", ", ": ")
|
||||
)
|
||||
|
||||
sys.stdout.write(f"{line}\r\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
Output = Union[RawOutput, AsciicastOutput]
|
||||
|
||||
|
||||
class Player: # pylint: disable=too-few-public-methods
|
||||
def play(
|
||||
@@ -15,17 +54,39 @@ class Player: # pylint: disable=too-few-public-methods
|
||||
idle_time_limit: Optional[int] = None,
|
||||
speed: float = 1.0,
|
||||
key_bindings: Optional[Dict[str, Any]] = None,
|
||||
out_fmt: str = "raw",
|
||||
stream: Optional[str] = None,
|
||||
pause_on_markers: bool = False,
|
||||
) -> None:
|
||||
if key_bindings is None:
|
||||
key_bindings = {}
|
||||
|
||||
output: Output = (
|
||||
RawOutput(stream) if out_fmt == "raw" else AsciicastOutput(stream)
|
||||
)
|
||||
|
||||
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
|
||||
asciicast,
|
||||
idle_time_limit,
|
||||
speed,
|
||||
stdin,
|
||||
key_bindings,
|
||||
output,
|
||||
pause_on_markers,
|
||||
)
|
||||
except IOError:
|
||||
self._play(
|
||||
asciicast,
|
||||
idle_time_limit,
|
||||
speed,
|
||||
None,
|
||||
key_bindings,
|
||||
output,
|
||||
False,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self._play(asciicast, idle_time_limit, speed, None, key_bindings)
|
||||
|
||||
@staticmethod
|
||||
def _play( # pylint: disable=too-many-locals
|
||||
@@ -34,63 +95,96 @@ class Player: # pylint: disable=too-few-public-methods
|
||||
speed: float,
|
||||
stdin: Optional[TextIO],
|
||||
key_bindings: Dict[str, Any],
|
||||
output: Output,
|
||||
pause_on_markers: bool,
|
||||
) -> None:
|
||||
idle_time_limit = idle_time_limit or asciicast.idle_time_limit
|
||||
pause_key = key_bindings.get("pause")
|
||||
step_key = key_bindings.get("step")
|
||||
next_marker_key = key_bindings.get("next_marker")
|
||||
|
||||
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)
|
||||
events = asciicast.events()
|
||||
events = ev.to_relative_time(events)
|
||||
events = ev.cap_relative_time(events, idle_time_limit)
|
||||
events = ev.to_absolute_time(events)
|
||||
events = ev.adjust_speed(events, speed)
|
||||
|
||||
output.start(asciicast.v2_header)
|
||||
|
||||
base_time = time.time()
|
||||
ctrl_c = False
|
||||
paused = False
|
||||
pause_time: Optional[float] = None
|
||||
pause_elapsed_time: Optional[float] = None
|
||||
events_iter = iter(events)
|
||||
start_time = time.perf_counter()
|
||||
|
||||
for t, _type, text in stdout:
|
||||
delay = t - (time.time() - base_time)
|
||||
def wait(timeout: int) -> bytes:
|
||||
if stdin is not None:
|
||||
return read_blocking(stdin.fileno(), timeout)
|
||||
|
||||
while stdin and not ctrl_c and delay > 0:
|
||||
if paused:
|
||||
while True:
|
||||
data = read_blocking(stdin.fileno(), 1000)
|
||||
return b""
|
||||
|
||||
if 0x03 in data: # ctrl-c
|
||||
def next_event() -> Any:
|
||||
try:
|
||||
return events_iter.__next__()
|
||||
except StopIteration:
|
||||
return (None, None, None)
|
||||
|
||||
time_, event_type, text = next_event()
|
||||
|
||||
while time_ is not None and not ctrl_c:
|
||||
if pause_elapsed_time:
|
||||
while time_ is not None:
|
||||
key = wait(1000)
|
||||
|
||||
if 0x03 in key: # ctrl-c
|
||||
ctrl_c = True
|
||||
break
|
||||
|
||||
if data == pause_key:
|
||||
paused = False
|
||||
assert pause_time is not None
|
||||
base_time += time.time() - pause_time
|
||||
if key == pause_key:
|
||||
assert pause_elapsed_time is not None
|
||||
start_time = time.perf_counter() - pause_elapsed_time
|
||||
pause_elapsed_time = None
|
||||
break
|
||||
|
||||
if data == step_key:
|
||||
delay = 0
|
||||
pause_time = time.time()
|
||||
base_time = pause_time - t
|
||||
break
|
||||
if key == step_key:
|
||||
pause_elapsed_time = time_
|
||||
output.write(time_, event_type, text)
|
||||
time_, event_type, text = next_event()
|
||||
|
||||
elif key == next_marker_key:
|
||||
while time_ is not None and event_type != "m":
|
||||
output.write(time_, event_type, text)
|
||||
time_, event_type, text = next_event()
|
||||
|
||||
if time_ is not None:
|
||||
output.write(time_, event_type, text)
|
||||
pause_elapsed_time = time_
|
||||
time_, event_type, text = next_event()
|
||||
else:
|
||||
data = read_blocking(stdin.fileno(), delay)
|
||||
while time_ is not None:
|
||||
elapsed_wall_time = time.perf_counter() - start_time
|
||||
delay = time_ - elapsed_wall_time
|
||||
key = b""
|
||||
|
||||
if not data:
|
||||
break
|
||||
if delay > 0:
|
||||
key = wait(delay)
|
||||
|
||||
if 0x03 in data: # ctrl-c
|
||||
if 0x03 in key: # 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
|
||||
elif key == pause_key:
|
||||
pause_elapsed_time = time.perf_counter() - start_time
|
||||
break
|
||||
|
||||
else:
|
||||
output.write(time_, event_type, text)
|
||||
|
||||
if event_type == "m" and pause_on_markers:
|
||||
pause_elapsed_time = time_
|
||||
time_, event_type, text = next_event()
|
||||
break
|
||||
|
||||
time_, event_type, text = next_event()
|
||||
|
||||
if ctrl_c:
|
||||
break
|
||||
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import array
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
@@ -19,6 +18,8 @@ EXIT_SIGNALS = [
|
||||
signal.SIGQUIT,
|
||||
]
|
||||
|
||||
READ_LEN = 256 * 1024
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements
|
||||
def record(
|
||||
@@ -37,6 +38,8 @@ def record(
|
||||
prefix_mode: bool = False
|
||||
prefix_key = key_bindings.get("prefix")
|
||||
pause_key = key_bindings.get("pause")
|
||||
add_marker_key = key_bindings.get("add_marker")
|
||||
input_data = bytes()
|
||||
|
||||
def set_pty_size() -> None:
|
||||
cols, rows = get_tty_size()
|
||||
@@ -44,13 +47,17 @@ def record(
|
||||
fcntl.ioctl(pty_fd, termios.TIOCSWINSZ, buf)
|
||||
|
||||
def handle_master_read(data: Any) -> None:
|
||||
os.write(tty_stdout_fd, data)
|
||||
remaining_data = memoryview(data)
|
||||
while remaining_data:
|
||||
n = os.write(tty_stdout_fd, remaining_data)
|
||||
remaining_data = remaining_data[n:]
|
||||
|
||||
if not pause_time:
|
||||
assert start_time is not None
|
||||
writer.write_stdout(time.time() - start_time, data)
|
||||
writer.write_stdout(time.perf_counter() - start_time, data)
|
||||
|
||||
def handle_stdin_read(data: Any) -> None:
|
||||
nonlocal input_data
|
||||
nonlocal pause_time
|
||||
nonlocal start_time
|
||||
nonlocal prefix_mode
|
||||
@@ -59,93 +66,111 @@ def record(
|
||||
prefix_mode = True
|
||||
return
|
||||
|
||||
if prefix_mode or (not prefix_key and data in [pause_key]):
|
||||
if prefix_mode or (
|
||||
not prefix_key and data in [pause_key, add_marker_key]
|
||||
):
|
||||
prefix_mode = False
|
||||
|
||||
if data == pause_key:
|
||||
if pause_time:
|
||||
assert start_time is not None
|
||||
start_time += time.time() - pause_time
|
||||
start_time += time.perf_counter() - pause_time
|
||||
pause_time = None
|
||||
notify("Resumed recording")
|
||||
else:
|
||||
pause_time = time.time()
|
||||
pause_time = time.perf_counter()
|
||||
notify("Paused recording")
|
||||
|
||||
elif data == add_marker_key:
|
||||
assert start_time is not None
|
||||
writer.write_marker(time.perf_counter() - start_time)
|
||||
notify("Marker added")
|
||||
|
||||
return
|
||||
|
||||
remaining_data = data
|
||||
while remaining_data:
|
||||
n = os.write(pty_fd, remaining_data)
|
||||
remaining_data = remaining_data[n:]
|
||||
input_data += data
|
||||
|
||||
if not pause_time:
|
||||
# save stdin unless paused or data is OSC response (e.g. \x1b]11;?\x07)
|
||||
if not pause_time and not (
|
||||
len(data) > 2
|
||||
and data[0] == 0x1B
|
||||
and data[1] == 0x5D
|
||||
and data[-1] == 0x07
|
||||
):
|
||||
assert start_time is not None
|
||||
writer.write_stdin(time.time() - start_time, data)
|
||||
writer.write_stdin(time.perf_counter() - 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
|
||||
nonlocal input_data
|
||||
|
||||
if not os.isatty(stdin_fd):
|
||||
fds.append(stdin_fd)
|
||||
crfds = [pty_fd, tty_stdin_fd, signal_fd]
|
||||
|
||||
while True:
|
||||
if len(input_data) > 0:
|
||||
cwfds = [pty_fd]
|
||||
else:
|
||||
cwfds = []
|
||||
|
||||
try:
|
||||
rfds, _, _ = select.select(fds, [], [])
|
||||
except OSError as e: # Python >= 3.3
|
||||
if e.errno == errno.EINTR:
|
||||
continue
|
||||
rfds, wfds, _ = select.select(crfds, cwfds, [])
|
||||
except KeyboardInterrupt:
|
||||
if tty_stdin_fd in crfds:
|
||||
crfds.remove(tty_stdin_fd)
|
||||
|
||||
break
|
||||
|
||||
if pty_fd in rfds:
|
||||
data = os.read(pty_fd, 1024)
|
||||
try:
|
||||
data = os.read(pty_fd, READ_LEN)
|
||||
except OSError as e:
|
||||
data = b""
|
||||
|
||||
if not data: # Reached EOF.
|
||||
fds.remove(pty_fd)
|
||||
break
|
||||
else:
|
||||
handle_master_read(data)
|
||||
|
||||
if tty_stdin_fd in rfds:
|
||||
data = os.read(tty_stdin_fd, 1024)
|
||||
data = os.read(tty_stdin_fd, READ_LEN)
|
||||
|
||||
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)
|
||||
if tty_stdin_fd in crfds:
|
||||
crfds.remove(tty_stdin_fd)
|
||||
else:
|
||||
handle_stdin_read(data)
|
||||
|
||||
if signal_fd in rfds:
|
||||
data = os.read(signal_fd, 1024)
|
||||
data = os.read(signal_fd, READ_LEN)
|
||||
|
||||
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
|
||||
crfds.remove(signal_fd)
|
||||
if sig == signal.SIGWINCH:
|
||||
set_pty_size()
|
||||
|
||||
if pty_fd in wfds:
|
||||
n = os.write(pty_fd, input_data)
|
||||
input_data = input_data[n:]
|
||||
|
||||
pid, pty_fd = pty.fork()
|
||||
|
||||
if pid == pty.CHILD:
|
||||
os.execvpe(command[0], command, env)
|
||||
|
||||
start_time = time.time()
|
||||
flags = fcntl.fcntl(pty_fd, fcntl.F_GETFL, 0) | os.O_NONBLOCK
|
||||
fcntl.fcntl(pty_fd, fcntl.F_SETFL, flags)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
set_pty_size()
|
||||
|
||||
with SignalFD(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd:
|
||||
with raw(tty_stdin_fd):
|
||||
try:
|
||||
copy(sig_fd)
|
||||
os.close(pty_fd)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type
|
||||
|
||||
@@ -77,22 +78,15 @@ class tty_fds:
|
||||
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())
|
||||
return (sys.stdin.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()
|
||||
|
||||
|
||||
@@ -137,7 +131,11 @@ class async_writer(async_worker):
|
||||
def write_stdout(self, ts: float, data: Any) -> None:
|
||||
self.enqueue([ts, "o", data])
|
||||
|
||||
def write_marker(self, ts: float) -> None:
|
||||
self.enqueue([ts, "m", None])
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
with self.writer as w:
|
||||
event: Tuple[float, str, Any]
|
||||
for event in iter(self.queue.get, None):
|
||||
@@ -148,6 +146,11 @@ class async_writer(async_worker):
|
||||
w.write_stdout(self.time_offset + ts, data)
|
||||
elif etype == "i":
|
||||
w.write_stdin(self.time_offset + ts, data)
|
||||
elif etype == "m":
|
||||
w.write_marker(self.time_offset + ts)
|
||||
except IOError:
|
||||
for event in iter(self.queue.get, None):
|
||||
pass
|
||||
|
||||
|
||||
class async_notifier(async_worker):
|
||||
|
||||
@@ -33,7 +33,7 @@ class MultipartFormdataEncoder:
|
||||
yield body's chunk as bytes
|
||||
"""
|
||||
encoder = codecs.getencoder("utf-8")
|
||||
for (key, value) in fields.items():
|
||||
for key, value in fields.items():
|
||||
key = self.u(key)
|
||||
yield encoder(f"--{self.boundary}\r\n")
|
||||
yield encoder(
|
||||
@@ -44,7 +44,7 @@ class MultipartFormdataEncoder:
|
||||
value = str(value)
|
||||
yield encoder(self.u(value))
|
||||
yield encoder("\r\n")
|
||||
for (key, filename_and_f) in files.items():
|
||||
for key, filename_and_f in files.items():
|
||||
filename, f = filename_and_f
|
||||
key = self.u(key)
|
||||
filename = self.u(filename)
|
||||
|
||||
@@ -13,7 +13,8 @@ Example file:
|
||||
{"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", " "]
|
||||
[1.500000, "m", ""]
|
||||
[2.143733, "o", "Now... "]
|
||||
[6.541828, "o", "Bye!"]
|
||||
```
|
||||
|
||||
@@ -114,7 +115,7 @@ 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-type` (string) - one of: `"o"`, `"i"`, `"m"`
|
||||
* `event-data` (any) - event specific data, described separately for each event
|
||||
type.
|
||||
|
||||
@@ -125,7 +126,7 @@ For example, let's look at the following line:
|
||||
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),
|
||||
* is of type `"o"` (output, write to a terminal, see below),
|
||||
* has data `"Hello world"`.
|
||||
|
||||
### Supported event types
|
||||
@@ -140,29 +141,41 @@ 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
|
||||
#### "o" - output, data written to the terminal
|
||||
|
||||
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
|
||||
`event-data` is a string containing the data that was printed. It must be valid,
|
||||
UTF-8 encoded JSON string as described in [JSON RFC section
|
||||
2.5](http://www.ietf.org/rfc/rfc4627.txt), with any non-printable Unicode
|
||||
codepoints encoded as `\uXXXX`.
|
||||
|
||||
#### "i" - input, data read from the terminal
|
||||
|
||||
Event of type `"i"` represents character typed in by the user, or more
|
||||
specifically, raw data sent from a terminal emulator to stdin of the recorded
|
||||
program (usually shell).
|
||||
|
||||
`event-data` is a string containing captured ASCII character representing a key,
|
||||
or a control character like `"\r"` (enter), `"\u0001"` (ctrl-a), `"\u0003"`
|
||||
(ctrl-c), etc. Like with `"o"` event, it's UTF-8 encoded JSON string, with any
|
||||
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
|
||||
> Official asciinema recorder doesn't capture keyboard input by default. All
|
||||
> implementations of asciicast-compatible terminal recorder should not capture
|
||||
> it either unless explicitly permitted by the user.
|
||||
|
||||
#### "m" - marker
|
||||
|
||||
Event of type `"m"` represents a marker.
|
||||
|
||||
When marker is encountered in the event stream and "pause on markers"
|
||||
functionality of the player is enabled, the playback should pause, and wait for
|
||||
the user to resume.
|
||||
|
||||
`event-data` can be used to annotate a marker. Annotations may be used to e.g.
|
||||
show a list of named "chapters".
|
||||
|
||||
## Notes on compatibility
|
||||
|
||||
Version 2 of asciicast file format solves several problems which couldn't be
|
||||
|
||||
@@ -331,10 +331,7 @@ More documentation is available on the asciicast.org website and its GitHub wiki
|
||||
|
||||
* Web: [asciinema.org/docs/](https://asciinema.org/docs/)
|
||||
* Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki)
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
* IRC: [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema)
|
||||
>>>>>>> develop
|
||||
* Twitter: [@asciinema](https://twitter.com/asciinema)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = asciinema
|
||||
version = 2.2.0
|
||||
version = 2.3.0
|
||||
author = Marcin Kulik
|
||||
author_email = m@ku1ik.com
|
||||
url = https://asciinema.org
|
||||
@@ -20,11 +20,11 @@ classifiers =
|
||||
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
|
||||
Programming Language :: Python :: 3.11
|
||||
Topic :: System :: Shells
|
||||
Topic :: Terminals
|
||||
Topic :: Utilities
|
||||
|
||||
@@ -23,6 +23,10 @@ asciinema() {
|
||||
python3 -m asciinema "${@}"
|
||||
}
|
||||
|
||||
## disable notifications
|
||||
|
||||
printf "[notifications]\nenabled = no\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
|
||||
|
||||
## test help message
|
||||
|
||||
asciinema -h
|
||||
@@ -93,3 +97,8 @@ asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/
|
||||
# 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"
|
||||
|
||||
# adding a marker
|
||||
printf "[record]\nadd_marker_key = C-b\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
|
||||
(bash -c "sleep 1; printf '.'; sleep 0.5; printf '\x08'; sleep 0.5; printf '\x02'; sleep 0.5; printf '\x04'") | asciinema rec -c /bin/bash "${TMP_DATA_DIR}/8.cast"
|
||||
grep '"m",' "${TMP_DATA_DIR}/8.cast"
|
||||
|
||||
Reference in New Issue
Block a user