Merge pull request #560 from asciinema/release/2.3.0

Release 2.3.0
This commit is contained in:
Marcin Kulik
2023-07-05 15:49:34 +02:00
committed by GitHub
30 changed files with 631 additions and 242 deletions

38
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View 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.

View File

@@ -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: |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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 &copy; 20112021 Marcin Kulik.
© 2011 Marcin Kulik.
All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file
for details.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,46 +59,50 @@ 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()
parser = Parser()
parser.feed(html)
new_url = parser.url
if content_type and content_type.startswith("text/html"):
html = utf8_reader(body, errors="replace").read()
parser = Parser()
parser.feed(html)
new_url = parser.url
if not new_url:
raise LoadError(
'<link rel="alternate" '
'type="application/x-asciicast" '
'href="..."> '
"not found in fetched HTML document"
if not new_url:
raise LoadError(
'<link rel="alternate" '
'type="application/x-asciicast" '
'href="..."> '
"not found in fetched HTML document"
)
if "://" not in new_url:
base_url = urlparse(url)
if 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, "", "", "")
)
if "://" not in new_url:
base_url = urlparse(url)
return open_url(new_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 utf8_reader(body, errors="strict")
return open(url, mode="rt", encoding="utf-8")

View File

@@ -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)

View File

@@ -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 == "-":

View File

@@ -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"

View File

@@ -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]:
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 events(
self, type_: Optional[str] = None
) -> Generator[List[Any], None, None]:
if type_ is None:
for line in self.__file:
yield json.loads(line)
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 == "-":

View File

@@ -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:

View File

@@ -10,16 +10,23 @@ 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():
sys.stdout.write(text)
sys.stdout.flush()
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()
except asciicast.LoadError as e:
self.print_error(f"printing failed: {str(e)}")

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,16 +83,30 @@ class RecordCommand(Command): # pylint: disable=too-many-instance-attributes
)
return 1
elif append:
self.print_warning(
f"{self.filename} does not exist, not appending"
)
append = False
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"
)
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.filename == "-":
self.print_info(f"recording asciicast to stdout")
else:
self.print_info(f"recording asciicast to {self.filename}")
if self.command:
self.print_info("""exit opened program when you're done""")
@@ -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

View File

@@ -25,9 +25,12 @@ class UploadCommand(Command):
except APIError as e:
self.print_error(f"upload failed: {str(e)}")
self.print_error(
f"retry later by running: asciinema upload {self.filename}"
)
if e.retryable:
self.print_error(
f"retry later by running: asciinema upload {self.filename}"
)
return 1
return 0

View File

@@ -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(

View File

@@ -1,5 +1,3 @@
import os
import stat
from typing import IO, Any, Callable, Optional
@@ -29,15 +27,9 @@ 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")
raise e
except IOError as e:
self.on_error("Write error, recording suspended")
raise e
def noop(_: Any) -> None:

View File

@@ -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 Exception: # pylint: disable=broad-except
self._play(asciicast, idle_time_limit, speed, None, key_bindings)
except IOError:
self._play(
asciicast,
idle_time_limit,
speed,
None,
key_bindings,
output,
False,
)
@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
ctrl_c = True
break
def next_event() -> Any:
try:
return events_iter.__next__()
except StopIteration:
return (None, None, None)
if data == pause_key:
paused = False
assert pause_time is not None
base_time += time.time() - pause_time
break
time_, event_type, text = next_event()
if data == step_key:
delay = 0
pause_time = time.time()
base_time = pause_time - t
break
else:
data = read_blocking(stdin.fileno(), delay)
while time_ is not None and not ctrl_c:
if pause_elapsed_time:
while time_ is not None:
key = wait(1000)
if not data:
break
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
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 ctrl_c:
break
if key == step_key:
pause_elapsed_time = time_
output.write(time_, event_type, text)
time_, event_type, text = next_event()
sys.stdout.write(text)
sys.stdout.flush()
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:
while time_ is not None:
elapsed_wall_time = time.perf_counter() - start_time
delay = time_ - elapsed_wall_time
key = b""
if delay > 0:
key = wait(delay)
if 0x03 in key: # ctrl-c
ctrl_c = True
break
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:
raise KeyboardInterrupt()

View File

@@ -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

View File

@@ -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,17 +131,26 @@ class async_writer(async_worker):
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
def write_marker(self, ts: float) -> None:
self.enqueue([ts, "m", None])
if etype == "o":
w.write_stdout(self.time_offset + ts, data)
elif etype == "i":
w.write_stdin(self.time_offset + ts, data)
def run(self) -> None:
try:
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)
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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"