mirror of
https://github.com/ihabunek/toot.git
synced 2024-06-30 06:35:24 +00:00
Compare commits
1 Commits
1accd662c9
...
2491ed668c
Author | SHA1 | Date | |
---|---|---|---|
|
2491ed668c |
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
|
@ -7,13 +7,12 @@ jobs:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
2
.vermin
2
.vermin
|
@ -1,4 +1,4 @@
|
||||||
[vermin]
|
[vermin]
|
||||||
only_show_violations = yes
|
only_show_violations = yes
|
||||||
show_tips = no
|
show_tips = no
|
||||||
targets = 3.8
|
targets = 3.7
|
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -3,16 +3,6 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||||
|
|
||||||
**0.43.0 (2024-04-13)**
|
|
||||||
|
|
||||||
* TUI: Support displaying images (thanks Dan Schwarz)
|
|
||||||
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
|
|
||||||
* Show visibility in timeline (thanks Sandra Snan)
|
|
||||||
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
|
|
||||||
Snan)
|
|
||||||
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
|
|
||||||
* Migrated to pyproject.toml finally
|
|
||||||
|
|
||||||
**0.42.0 (2024-03-09)**
|
**0.42.0 (2024-03-09)**
|
||||||
|
|
||||||
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
||||||
|
|
5
Makefile
5
Makefile
|
@ -1,7 +1,8 @@
|
||||||
.PHONY: clean publish test docs
|
.PHONY: clean publish test docs
|
||||||
|
|
||||||
dist:
|
dist :
|
||||||
python -m build
|
python setup.py sdist --formats=gztar,zip
|
||||||
|
python setup.py bdist_wheel --python-tag=py3
|
||||||
|
|
||||||
publish :
|
publish :
|
||||||
twine upload dist/*.tar.gz dist/*.whl
|
twine upload dist/*.tar.gz dist/*.whl
|
||||||
|
|
|
@ -1,18 +1,3 @@
|
||||||
0.44.0:
|
|
||||||
date: TBA
|
|
||||||
changes:
|
|
||||||
- "**BREAKING:** Require Python 3.8+"
|
|
||||||
|
|
||||||
0.43.0:
|
|
||||||
date: 2024-04-13
|
|
||||||
changes:
|
|
||||||
- "TUI: Support displaying images (thanks Dan Schwarz)"
|
|
||||||
- "Improve GoToSocial compatibility (thanks Luca Matei Pintilie)"
|
|
||||||
- "Show visibility in timeline (thanks Sandra Snan)"
|
|
||||||
- "Flag `notifications --clear` no longer requires an argument (thanks Sandra Snan)"
|
|
||||||
- "TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)"
|
|
||||||
- "Migrated to pyproject.toml finally"
|
|
||||||
|
|
||||||
0.42.0:
|
0.42.0:
|
||||||
date: 2024-03-09
|
date: 2024-03-09
|
||||||
changes:
|
changes:
|
||||||
|
|
|
@ -3,16 +3,6 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||||
|
|
||||||
**0.43.0 (2024-04-13)**
|
|
||||||
|
|
||||||
* TUI: Support displaying images (thanks Dan Schwarz)
|
|
||||||
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
|
|
||||||
* Show visibility in timeline (thanks Sandra Snan)
|
|
||||||
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
|
|
||||||
Snan)
|
|
||||||
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
|
|
||||||
* Migrated to pyproject.toml finally
|
|
||||||
|
|
||||||
**0.42.0 (2024-03-09)**
|
**0.42.0 (2024-03-09)**
|
||||||
|
|
||||||
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
||||||
|
|
|
@ -5,11 +5,13 @@ This document is a checklist for creating a toot release.
|
||||||
|
|
||||||
Currently the process is pretty manual and would benefit from automatization.
|
Currently the process is pretty manual and would benefit from automatization.
|
||||||
|
|
||||||
Make docs and tag version
|
Bump & tag version
|
||||||
-------------------------
|
------------------
|
||||||
|
|
||||||
|
* Update the version number in `setup.py`
|
||||||
|
* Update the version number in `toot/__init__.py`
|
||||||
* Update `changelog.yaml` with the release notes & date
|
* Update `changelog.yaml` with the release notes & date
|
||||||
* Run `make docs` to generate changelog and update docs
|
* Run `make changelog` to generate a human readable changelog
|
||||||
* Commit the changes
|
* Commit the changes
|
||||||
* Run `./scripts/tag_version <version>` to tag a release in git
|
* Run `./scripts/tag_version <version>` to tag a release in git
|
||||||
* Run `git push --follow-tags` to upload changes and tag to GitHub
|
* Run `git push --follow-tags` to upload changes and tag to GitHub
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=64", "setuptools_scm>=8"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "toot"
|
|
||||||
authors = [{ name="Ivan Habunek", email="ivan@habunek.com" }]
|
|
||||||
description = "Mastodon CLI client"
|
|
||||||
readme = "README.rst"
|
|
||||||
license = { file="LICENSE" }
|
|
||||||
requires-python = ">=3.8"
|
|
||||||
dynamic = ["version"]
|
|
||||||
|
|
||||||
classifiers = [
|
|
||||||
"Environment :: Console :: Curses",
|
|
||||||
"Environment :: Console",
|
|
||||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
]
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
"beautifulsoup4>=4.5.0,<5.0",
|
|
||||||
"click~=8.1",
|
|
||||||
"requests>=2.13,<3.0",
|
|
||||||
"tomlkit>=0.10.0,<1.0",
|
|
||||||
"urwid>=2.0.0,<3.0",
|
|
||||||
"wcwidth>=0.1.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
# Required to display images in the TUI
|
|
||||||
images = [
|
|
||||||
"pillow>=9.5.0",
|
|
||||||
"term-image==0.7.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Required to display rich text in the TUI
|
|
||||||
richtext = [
|
|
||||||
"urwidgets>=0.1,<0.2"
|
|
||||||
]
|
|
||||||
|
|
||||||
test = [
|
|
||||||
"flake8",
|
|
||||||
"pytest",
|
|
||||||
"pytest-xdist[psutil]",
|
|
||||||
"setuptools",
|
|
||||||
"vermin",
|
|
||||||
"typing-extensions",
|
|
||||||
"pillow>=9.5.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
dev = [
|
|
||||||
"build",
|
|
||||||
"flake8",
|
|
||||||
"mypy",
|
|
||||||
"pyright",
|
|
||||||
"pyyaml",
|
|
||||||
"textual-dev",
|
|
||||||
"twine",
|
|
||||||
"types-beautifulsoup4",
|
|
||||||
"vermin",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
"Homepage" = "https://toot.bezdomni.net"
|
|
||||||
"Source" = "https://github.com/ihabunek/toot/"
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
toot = "toot.cli:cli"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
packages=[
|
|
||||||
"toot",
|
|
||||||
"toot.cli",
|
|
||||||
"toot.tui",
|
|
||||||
"toot.tui.richtext",
|
|
||||||
"toot.utils"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
|
||||||
|
|
||||||
[tool.pyright]
|
|
||||||
include = ["toot"]
|
|
||||||
typeCheckingMode = "strict"
|
|
|
@ -16,6 +16,7 @@ import toot
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from os import path
|
from os import path
|
||||||
|
from pkg_resources import get_distribution
|
||||||
|
|
||||||
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
|
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
|
||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
|
@ -32,6 +33,15 @@ if not changelog_item:
|
||||||
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
|
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if toot.__version__ != version:
|
||||||
|
print(f"toot.__version__ is `{toot.__version__}`, expected {version}.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
dist_version = get_distribution('toot').version
|
||||||
|
if dist_version != version:
|
||||||
|
print(f"Version in setup.py is `{dist_version}`, expected {version}.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
release_date = changelog_item["date"]
|
release_date = changelog_item["date"]
|
||||||
description = changelog_item.get("description")
|
description = changelog_item.get("description")
|
||||||
changes = changelog_item["changes"]
|
changes = changelog_item["changes"]
|
||||||
|
|
70
setup.py
Normal file
70
setup.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
long_description = """
|
||||||
|
Toot is a CLI and TUI tool for interacting with Mastodon instances from the
|
||||||
|
command line.
|
||||||
|
|
||||||
|
Allows posting text and media to the timeline, searching, following, muting
|
||||||
|
and blocking accounts and other actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='toot',
|
||||||
|
version='0.42.0',
|
||||||
|
description='Mastodon CLI client',
|
||||||
|
long_description=long_description.strip(),
|
||||||
|
author='Ivan Habunek',
|
||||||
|
author_email='ivan@habunek.com',
|
||||||
|
url='https://github.com/ihabunek/toot/',
|
||||||
|
project_urls={
|
||||||
|
'Documentation': 'https://toot.bezdomni.net/',
|
||||||
|
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
|
||||||
|
},
|
||||||
|
keywords='mastodon toot',
|
||||||
|
license='GPLv3',
|
||||||
|
classifiers=[
|
||||||
|
'Development Status :: 4 - Beta',
|
||||||
|
'Environment :: Console :: Curses',
|
||||||
|
'Environment :: Console',
|
||||||
|
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
],
|
||||||
|
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
|
||||||
|
python_requires=">=3.7",
|
||||||
|
install_requires=[
|
||||||
|
"click~=8.1",
|
||||||
|
"requests>=2.13,<3.0",
|
||||||
|
"beautifulsoup4>=4.5.0,<5.0",
|
||||||
|
"wcwidth>=0.1.7",
|
||||||
|
"urwid>=2.0.0,<3.0",
|
||||||
|
"tomlkit>=0.10.0,<1.0"
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
# Required to display rich text in the TUI
|
||||||
|
"richtext": [
|
||||||
|
"urwidgets>=0.1,<0.2"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"coverage",
|
||||||
|
"pyyaml",
|
||||||
|
"twine",
|
||||||
|
"wheel",
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"flake8",
|
||||||
|
"psycopg2-binary",
|
||||||
|
"pytest",
|
||||||
|
"pytest-xdist[psutil]",
|
||||||
|
"setuptools",
|
||||||
|
"vermin",
|
||||||
|
"typing-extensions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'toot=toot.cli:cli',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
|
@ -41,8 +41,6 @@ TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
|
||||||
|
|
||||||
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
|
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
|
||||||
|
|
||||||
PASSWORD = "83dU29170rjKilKQQwuWhJv3PKnSW59bWx0perjP6i7Nu4rkeh4mRfYuvVLYM3fM"
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(base_url):
|
def create_app(base_url):
|
||||||
instance = api.get_instance(base_url).json()
|
instance = api.get_instance(base_url).json()
|
||||||
|
@ -54,7 +52,7 @@ def register_account(app: App):
|
||||||
username = str(uuid.uuid4())[-10:]
|
username = str(uuid.uuid4())[-10:]
|
||||||
email = f"{username}@example.com"
|
email = f"{username}@example.com"
|
||||||
|
|
||||||
response = api.register_account(app, username, email, PASSWORD, "en")
|
response = api.register_account(app, username, email, "password", "en")
|
||||||
return User(app.instance, username, response["access_token"])
|
return User(app.instance, username, response["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ from unittest import mock
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from toot import User, cli
|
from toot import User, cli
|
||||||
from tests.integration.conftest import PASSWORD, Run
|
from tests.integration.conftest import Run
|
||||||
|
|
||||||
# TODO: figure out how to test login
|
# TODO: figure out how to test login
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ def test_login_cli(
|
||||||
cli.auth.login_cli,
|
cli.auth.login_cli,
|
||||||
"--instance", "http://localhost:3000",
|
"--instance", "http://localhost:3000",
|
||||||
"--email", f"{user.username}@example.com",
|
"--email", f"{user.username}@example.com",
|
||||||
"--password", PASSWORD,
|
"--password", "password",
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "✓ Successfully logged in." in result.stdout
|
assert "✓ Successfully logged in." in result.stdout
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import click
|
import click
|
||||||
import pytest
|
import pytest
|
||||||
import sys
|
|
||||||
|
|
||||||
from toot.cli.validators import validate_duration
|
from toot.cli.validators import validate_duration
|
||||||
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
||||||
from toot.tui.utils import LRUCache
|
|
||||||
from PIL import Image
|
|
||||||
from collections import namedtuple
|
|
||||||
from toot.utils import urlencode_url
|
from toot.utils import urlencode_url
|
||||||
|
|
||||||
|
|
||||||
|
@ -211,111 +207,6 @@ def test_duration():
|
||||||
duration("banana")
|
duration("banana")
|
||||||
|
|
||||||
|
|
||||||
def test_cache_null():
|
|
||||||
"""Null dict is null."""
|
|
||||||
cache = LRUCache(cache_max_bytes=1024)
|
|
||||||
assert cache.__len__() == 0
|
|
||||||
|
|
||||||
|
|
||||||
Case = namedtuple("Case", ["cache_len", "len", "init"])
|
|
||||||
|
|
||||||
img = Image.new('RGB', (100, 100))
|
|
||||||
img_size = sys.getsizeof(img.tobytes())
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"case",
|
|
||||||
[
|
|
||||||
Case(9, 0, []),
|
|
||||||
Case(9, 1, [("one", img)]),
|
|
||||||
Case(9, 2, [("one", img), ("two", img)]),
|
|
||||||
Case(2, 2, [("one", img), ("two", img)]),
|
|
||||||
Case(1, 1, [("one", img), ("two", img)]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize("method", ["assign", "init"])
|
|
||||||
def test_cache_init(case, method):
|
|
||||||
"""Check that the # of elements is right, given # given and cache_len."""
|
|
||||||
if method == "init":
|
|
||||||
cache = LRUCache(case.init, cache_max_bytes=img_size * case.cache_len)
|
|
||||||
elif method == "assign":
|
|
||||||
cache = LRUCache(cache_max_bytes=img_size * case.cache_len)
|
|
||||||
for (key, val) in case.init:
|
|
||||||
cache[key] = val
|
|
||||||
else:
|
|
||||||
assert False
|
|
||||||
|
|
||||||
# length is max(#entries, cache_len)
|
|
||||||
assert cache.__len__() == case.len
|
|
||||||
|
|
||||||
# make sure the first entry is the one ejected
|
|
||||||
if case.cache_len > 1 and case.init:
|
|
||||||
assert "one" in cache.keys()
|
|
||||||
else:
|
|
||||||
assert "one" not in cache.keys()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", ["init", "assign"])
|
|
||||||
def test_cache_overflow_default(method):
|
|
||||||
"""Test default overflow logic."""
|
|
||||||
if method == "init":
|
|
||||||
cache = LRUCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2)
|
|
||||||
elif method == "assign":
|
|
||||||
cache = LRUCache(cache_max_bytes=img_size * 2)
|
|
||||||
cache["one"] = img
|
|
||||||
cache["two"] = img
|
|
||||||
cache["three"] = img
|
|
||||||
else:
|
|
||||||
assert False
|
|
||||||
|
|
||||||
assert "one" not in cache.keys()
|
|
||||||
assert "two" in cache.keys()
|
|
||||||
assert "three" in cache.keys()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("mode", ["get", "set"])
|
|
||||||
@pytest.mark.parametrize("add_third", [False, True])
|
|
||||||
def test_cache_lru_overflow(mode, add_third):
|
|
||||||
img = Image.new('RGB', (100, 100))
|
|
||||||
img_size = sys.getsizeof(img.tobytes())
|
|
||||||
|
|
||||||
"""Test that key access resets LRU logic."""
|
|
||||||
|
|
||||||
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2)
|
|
||||||
|
|
||||||
if mode == "get":
|
|
||||||
dummy = cache["one"]
|
|
||||||
elif mode == "set":
|
|
||||||
cache["one"] = img
|
|
||||||
else:
|
|
||||||
assert False
|
|
||||||
|
|
||||||
if add_third:
|
|
||||||
cache["three"] = img
|
|
||||||
|
|
||||||
assert "one" in cache.keys()
|
|
||||||
assert "two" not in cache.keys()
|
|
||||||
assert "three" in cache.keys()
|
|
||||||
else:
|
|
||||||
assert "one" in cache.keys()
|
|
||||||
assert "two" in cache.keys()
|
|
||||||
assert "three" not in cache.keys()
|
|
||||||
|
|
||||||
|
|
||||||
def test_cache_keyerror():
|
|
||||||
cache = LRUCache()
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
cache["foo"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cache_miss_doesnt_eject():
|
|
||||||
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3)
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
cache["foo"]
|
|
||||||
|
|
||||||
assert len(cache) == 2
|
|
||||||
assert "one" in cache.keys()
|
|
||||||
assert "two" in cache.keys()
|
|
||||||
|
|
||||||
def test_urlencode_url():
|
def test_urlencode_url():
|
||||||
assert urlencode_url("https://www.example.com") == "https://www.example.com"
|
assert urlencode_url("https://www.example.com") == "https://www.example.com"
|
||||||
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"
|
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,28 @@ Helpers for testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Callable, TypeVar
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
class MockResponse:
|
||||||
|
def __init__(self, response_data={}, ok=True, is_redirect=False):
|
||||||
|
self.response_data = response_data
|
||||||
|
self.content = response_data
|
||||||
|
self.ok = ok
|
||||||
|
self.is_redirect = is_redirect
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self.response_data
|
||||||
|
|
||||||
|
|
||||||
def run_with_retries(fn: Callable[..., T]) -> T:
|
def retval(val):
|
||||||
|
return lambda *args, **kwargs: val
|
||||||
|
|
||||||
|
|
||||||
|
def run_with_retries(fn: Callable[..., Any]):
|
||||||
"""
|
"""
|
||||||
Run the the given function repeatedly until it finishes without raising an
|
Run the the given function repeatedly until it finishes without raising an
|
||||||
AssertionError. Sleep a bit between attempts. If the function doesn't
|
AssertionError. Sleep a bit between attempts. If the function doesn't
|
||||||
|
@ -26,4 +41,4 @@ def run_with_retries(fn: Callable[..., T]) -> T:
|
||||||
except AssertionError:
|
except AssertionError:
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
||||||
return fn()
|
fn()
|
||||||
|
|
|
@ -3,13 +3,8 @@ import sys
|
||||||
|
|
||||||
from os.path import join, expanduser
|
from os.path import join, expanduser
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from importlib import metadata
|
|
||||||
|
|
||||||
|
__version__ = '0.42.0'
|
||||||
try:
|
|
||||||
__version__ = metadata.version("toot")
|
|
||||||
except metadata.PackageNotFoundError:
|
|
||||||
__version__ = "0.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
class App(NamedTuple):
|
class App(NamedTuple):
|
||||||
|
|
|
@ -22,7 +22,7 @@ T = t.TypeVar("T")
|
||||||
|
|
||||||
PRIVACY_CHOICES = ["public", "unlisted", "private"]
|
PRIVACY_CHOICES = ["public", "unlisted", "private"]
|
||||||
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
||||||
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
|
|
||||||
TUI_COLORS = {
|
TUI_COLORS = {
|
||||||
"1": 1,
|
"1": 1,
|
||||||
"16": 16,
|
"16": 16,
|
||||||
|
|
|
@ -111,10 +111,7 @@ def bookmarks(
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option(
|
@click.option("--clear", help="Dismiss all notifications and exit")
|
||||||
"--clear", is_flag=True,
|
|
||||||
help="Dismiss all notifications and exit"
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--reverse", "-r", is_flag=True,
|
"--reverse", "-r", is_flag=True,
|
||||||
help="Reverse the order of the shown notifications (newest on top)"
|
help="Reverse the order of the shown notifications (newest on top)"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, IMAGE_FORMAT_CHOICES, Context, cli, pass_context
|
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context
|
||||||
from toot.cli.validators import validate_tui_colors, validate_cache_size
|
from toot.cli.validators import validate_tui_colors
|
||||||
from toot.tui.app import TUI, TuiOptions
|
from toot.tui.app import TUI, TuiOptions
|
||||||
|
|
||||||
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
|
@ -24,12 +24,6 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
|
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
|
||||||
using --color, and 1 if using --no-color."""
|
using --color, and 1 if using --no-color."""
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"-s", "--cache-size",
|
|
||||||
callback=validate_cache_size,
|
|
||||||
help="""Specify the image cache maximum size in megabytes. Default: 10MB.
|
|
||||||
Minimum: 1MB."""
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"-v", "--default-visibility",
|
"-v", "--default-visibility",
|
||||||
type=click.Choice(VISIBILITY_CHOICES),
|
type=click.Choice(VISIBILITY_CHOICES),
|
||||||
|
@ -40,11 +34,6 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="Expand toots with content warnings automatically"
|
help="Expand toots with content warnings automatically"
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"-f", "--image-format",
|
|
||||||
type=click.Choice(IMAGE_FORMAT_CHOICES),
|
|
||||||
help="Image output format; support varies across terminals. Default: block"
|
|
||||||
)
|
|
||||||
@pass_context
|
@pass_context
|
||||||
def tui(
|
def tui(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
@ -52,9 +41,7 @@ def tui(
|
||||||
media_viewer: Optional[str],
|
media_viewer: Optional[str],
|
||||||
always_show_sensitive: bool,
|
always_show_sensitive: bool,
|
||||||
relative_datetimes: bool,
|
relative_datetimes: bool,
|
||||||
cache_size: Optional[int],
|
default_visibility: Optional[str]
|
||||||
default_visibility: Optional[str],
|
|
||||||
image_format: Optional[str]
|
|
||||||
):
|
):
|
||||||
"""Launches the toot terminal user interface"""
|
"""Launches the toot terminal user interface"""
|
||||||
if colors is None:
|
if colors is None:
|
||||||
|
@ -64,10 +51,8 @@ def tui(
|
||||||
colors=colors,
|
colors=colors,
|
||||||
media_viewer=media_viewer,
|
media_viewer=media_viewer,
|
||||||
relative_datetimes=relative_datetimes,
|
relative_datetimes=relative_datetimes,
|
||||||
cache_size=cache_size,
|
|
||||||
default_visibility=default_visibility,
|
default_visibility=default_visibility,
|
||||||
always_show_sensitive=always_show_sensitive,
|
always_show_sensitive=always_show_sensitive,
|
||||||
image_format=image_format,
|
|
||||||
)
|
)
|
||||||
tui = TUI.create(ctx.app, ctx.user, options)
|
tui = TUI.create(ctx.app, ctx.user, options)
|
||||||
tui.run()
|
tui.run()
|
||||||
|
|
|
@ -73,21 +73,3 @@ def validate_tui_colors(ctx, param, value) -> Optional[int]:
|
||||||
return TUI_COLORS[value]
|
return TUI_COLORS[value]
|
||||||
|
|
||||||
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
|
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
|
||||||
|
|
||||||
|
|
||||||
def validate_cache_size(ctx: click.Context, param: str, value: Optional[str]) -> Optional[int]:
|
|
||||||
"""validates the cache size parameter"""
|
|
||||||
|
|
||||||
if value is None:
|
|
||||||
return 1024 * 1024 * 10 # default 10MB
|
|
||||||
else:
|
|
||||||
if value.isdigit():
|
|
||||||
size = int(value)
|
|
||||||
else:
|
|
||||||
raise click.BadParameter("Cache size must be numeric.")
|
|
||||||
|
|
||||||
if size > 1024:
|
|
||||||
raise click.BadParameter("Cache size too large: 1024MB maximum.")
|
|
||||||
elif size < 1:
|
|
||||||
raise click.BadParameter("Cache size too small: 1MB minimum.")
|
|
||||||
return size
|
|
||||||
|
|
|
@ -15,8 +15,9 @@ from dataclasses import dataclass, is_dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Dict, Optional, Tuple, Type, TypeVar, Union
|
from typing import Any, Dict, Optional, Tuple, Type, TypeVar, Union
|
||||||
from typing import get_args, get_origin, get_type_hints
|
from typing import get_type_hints
|
||||||
|
|
||||||
|
from toot.typing_compat import get_args, get_origin
|
||||||
from toot.utils import get_text
|
from toot.utils import get_text
|
||||||
from toot.utils.datetime import parse_datetime
|
from toot.utils.datetime import parse_datetime
|
||||||
|
|
||||||
|
|
|
@ -219,7 +219,7 @@ def status_lines(status: Status) -> t.Generator[str, None, None]:
|
||||||
|
|
||||||
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
||||||
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
||||||
yield f"ID {yellow(status_id)} Visibility: {status.visibility} {reply} {boost}"
|
yield f"ID {yellow(status_id)} {reply} {boost}"
|
||||||
|
|
||||||
|
|
||||||
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
|
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
|
||||||
|
|
|
@ -2,7 +2,6 @@ import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import urwid
|
import urwid
|
||||||
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
@ -16,12 +15,11 @@ from toot.utils.datetime import parse_datetime
|
||||||
from .compose import StatusComposer
|
from .compose import StatusComposer
|
||||||
from .constants import PALETTE
|
from .constants import PALETTE
|
||||||
from .entities import Status
|
from .entities import Status
|
||||||
from .images import TuiScreen, load_image
|
|
||||||
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
|
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
|
||||||
from .overlays import StatusDeleteConfirmation, Account
|
from .overlays import StatusDeleteConfirmation, Account
|
||||||
from .poll import Poll
|
from .poll import Poll
|
||||||
from .timeline import Timeline
|
from .timeline import Timeline
|
||||||
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache
|
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
|
||||||
from .widgets import ModalBox, RoundedLineBox
|
from .widgets import ModalBox, RoundedLineBox
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -37,9 +35,7 @@ class TuiOptions(NamedTuple):
|
||||||
media_viewer: Optional[str]
|
media_viewer: Optional[str]
|
||||||
always_show_sensitive: bool
|
always_show_sensitive: bool
|
||||||
relative_datetimes: bool
|
relative_datetimes: bool
|
||||||
cache_size: int
|
|
||||||
default_visibility: Optional[str]
|
default_visibility: Optional[str]
|
||||||
image_format: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
class Header(urwid.WidgetWrap):
|
class Header(urwid.WidgetWrap):
|
||||||
|
@ -99,7 +95,7 @@ class TUI(urwid.Frame):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(app: App, user: User, args: TuiOptions):
|
def create(app: App, user: User, args: TuiOptions):
|
||||||
"""Factory method, sets up TUI and an event loop."""
|
"""Factory method, sets up TUI and an event loop."""
|
||||||
screen = TuiScreen()
|
screen = urwid.raw_display.Screen()
|
||||||
screen.set_terminal_properties(args.colors)
|
screen.set_terminal_properties(args.colors)
|
||||||
|
|
||||||
tui = TUI(app, user, screen, args)
|
tui = TUI(app, user, screen, args)
|
||||||
|
@ -148,11 +144,6 @@ class TUI(urwid.Frame):
|
||||||
self.followed_accounts = []
|
self.followed_accounts = []
|
||||||
self.preferences = {}
|
self.preferences = {}
|
||||||
|
|
||||||
if self.options.cache_size:
|
|
||||||
self.cache_max = 1024 * 1024 * self.options.cache_size
|
|
||||||
else:
|
|
||||||
self.cache_max = 1024 * 1024 * 10 # default 10MB
|
|
||||||
|
|
||||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
super().__init__(self.body, header=self.header, footer=self.footer)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
@ -336,10 +327,8 @@ class TUI(urwid.Frame):
|
||||||
# get the major version number of the server
|
# get the major version number of the server
|
||||||
# this works for Mastodon and Pleroma version strings
|
# this works for Mastodon and Pleroma version strings
|
||||||
# Mastodon versions < 4 do not have translation service
|
# Mastodon versions < 4 do not have translation service
|
||||||
# If the version is missing, assume 0 as a fallback
|
|
||||||
# Revisit this logic if Pleroma implements translation
|
# Revisit this logic if Pleroma implements translation
|
||||||
version = instance["version"]
|
ch = instance["version"][0]
|
||||||
ch = "0" if not version else version[0]
|
|
||||||
self.can_translate = int(ch) > 3 if ch.isnumeric() else False
|
self.can_translate = int(ch) > 3 if ch.isnumeric() else False
|
||||||
|
|
||||||
return self.run_in_thread(_load_instance, done_callback=_done)
|
return self.run_in_thread(_load_instance, done_callback=_done)
|
||||||
|
@ -657,7 +646,7 @@ class TUI(urwid.Frame):
|
||||||
account = api.whois(self.app, self.user, account_id)
|
account = api.whois(self.app, self.user, account_id)
|
||||||
relationship = api.get_relationship(self.app, self.user, account_id)
|
relationship = api.get_relationship(self.app, self.user, account_id)
|
||||||
self.open_overlay(
|
self.open_overlay(
|
||||||
widget=Account(self.app, self.user, account, relationship, self.options),
|
widget=Account(self.app, self.user, account, relationship),
|
||||||
title="Account",
|
title="Account",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -766,27 +755,6 @@ class TUI(urwid.Frame):
|
||||||
|
|
||||||
return self.run_in_thread(_delete, done_callback=_done)
|
return self.run_in_thread(_delete, done_callback=_done)
|
||||||
|
|
||||||
def async_load_image(self, timeline, status, path, placeholder_index):
|
|
||||||
def _load():
|
|
||||||
# don't bother loading images for statuses we are not viewing now
|
|
||||||
if timeline.get_focused_status().id != status.id:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not hasattr(timeline, "images"):
|
|
||||||
timeline.images = LRUCache(cache_max_bytes=self.cache_max)
|
|
||||||
|
|
||||||
img = load_image(path)
|
|
||||||
if img:
|
|
||||||
timeline.images[str(hash(path))] = img
|
|
||||||
|
|
||||||
def _done(loop):
|
|
||||||
# don't bother loading images for statuses we are not viewing now
|
|
||||||
if timeline.get_focused_status().id != status.id:
|
|
||||||
return
|
|
||||||
timeline.update_status_image(status, path, placeholder_index)
|
|
||||||
|
|
||||||
return self.run_in_thread(_load, done_callback=_done)
|
|
||||||
|
|
||||||
def copy_status(self, status):
|
def copy_status(self, status):
|
||||||
# TODO: copy a better version of status content
|
# TODO: copy a better version of status content
|
||||||
# including URLs
|
# including URLs
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
import urwid
|
|
||||||
import math
|
|
||||||
import requests
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
# If term_image is loaded use their screen implementation which handles images
|
|
||||||
try:
|
|
||||||
from term_image.widget import UrwidImageScreen, UrwidImage
|
|
||||||
from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage
|
|
||||||
from term_image import disable_queries # prevent phantom keystrokes
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
TuiScreen = UrwidImageScreen
|
|
||||||
disable_queries()
|
|
||||||
|
|
||||||
def image_support_enabled():
|
|
||||||
return True
|
|
||||||
|
|
||||||
def can_render_pixels(image_format):
|
|
||||||
return image_format in ['kitty', 'iterm']
|
|
||||||
|
|
||||||
def get_base_image(image, image_format) -> BaseImage:
|
|
||||||
# we don't autodetect kitty, iterm; we choose based on option switches
|
|
||||||
BaseImage.forced_support = True
|
|
||||||
if image_format == 'kitty':
|
|
||||||
return KittyImage(image)
|
|
||||||
elif image_format == 'iterm':
|
|
||||||
return ITerm2Image(image)
|
|
||||||
else:
|
|
||||||
return BlockImage(image)
|
|
||||||
|
|
||||||
def resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image:
|
|
||||||
if baseheight and not basewidth:
|
|
||||||
hpercent = baseheight / float(img.size[1])
|
|
||||||
width = math.ceil(img.size[0] * hpercent)
|
|
||||||
img = img.resize((width, baseheight), Image.Resampling.LANCZOS)
|
|
||||||
elif basewidth and not baseheight:
|
|
||||||
wpercent = (basewidth / float(img.size[0]))
|
|
||||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
|
||||||
img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
if img.mode != 'P':
|
|
||||||
img = img.convert('RGB')
|
|
||||||
return img
|
|
||||||
|
|
||||||
def add_corners(img, rad):
|
|
||||||
circle = Image.new('L', (rad * 2, rad * 2), 0)
|
|
||||||
draw = ImageDraw.Draw(circle)
|
|
||||||
draw.ellipse((0, 0, rad * 2, rad * 2), fill=255)
|
|
||||||
alpha = Image.new('L', img.size, "white")
|
|
||||||
w, h = img.size
|
|
||||||
alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0))
|
|
||||||
alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad))
|
|
||||||
alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0))
|
|
||||||
alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad))
|
|
||||||
img.putalpha(alpha)
|
|
||||||
return img
|
|
||||||
|
|
||||||
def load_image(url):
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL
|
|
||||||
try:
|
|
||||||
img = Image.open(requests.get(url, stream=True).raw)
|
|
||||||
if img.format == 'PNG' and img.mode != 'RGBA':
|
|
||||||
img = img.convert("RGBA")
|
|
||||||
return img
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
|
|
||||||
if not img:
|
|
||||||
return urwid.SolidFill(fill_char=" ")
|
|
||||||
|
|
||||||
if can_render_pixels(image_format) and corner_radius > 0:
|
|
||||||
render_img = add_corners(img, 10)
|
|
||||||
else:
|
|
||||||
render_img = img
|
|
||||||
|
|
||||||
return UrwidImage(get_base_image(render_img, image_format), '<', upscale=True)
|
|
||||||
# "<" means left-justify the image
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
from urwid.raw_display import Screen
|
|
||||||
TuiScreen = Screen
|
|
||||||
|
|
||||||
def image_support_enabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
def can_render_pixels(image_format: str):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_base_image(image, image_format: str):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add_corners(img, rad):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_image(url):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
|
|
||||||
return urwid.SolidFill(fill_char=" ")
|
|
|
@ -5,9 +5,7 @@ import webbrowser
|
||||||
|
|
||||||
from toot import __version__
|
from toot import __version__
|
||||||
from toot import api
|
from toot import api
|
||||||
|
|
||||||
from toot.tui.utils import highlight_keys
|
from toot.tui.utils import highlight_keys
|
||||||
from toot.tui.images import image_support_enabled, load_image, graphics_widget
|
|
||||||
from toot.tui.widgets import Button, EditBox, SelectableText
|
from toot.tui.widgets import Button, EditBox, SelectableText
|
||||||
from toot.tui.richtext import html_to_widgets
|
from toot.tui.richtext import html_to_widgets
|
||||||
|
|
||||||
|
@ -244,12 +242,11 @@ class Help(urwid.Padding):
|
||||||
|
|
||||||
class Account(urwid.ListBox):
|
class Account(urwid.ListBox):
|
||||||
"""Shows account data and provides various actions"""
|
"""Shows account data and provides various actions"""
|
||||||
def __init__(self, app, user, account, relationship, options):
|
def __init__(self, app, user, account, relationship):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.user = user
|
self.user = user
|
||||||
self.account = account
|
self.account = account
|
||||||
self.relationship = relationship
|
self.relationship = relationship
|
||||||
self.options = options
|
|
||||||
self.last_action = None
|
self.last_action = None
|
||||||
self.setup_listbox()
|
self.setup_listbox()
|
||||||
|
|
||||||
|
@ -258,30 +255,6 @@ class Account(urwid.ListBox):
|
||||||
walker = urwid.SimpleListWalker(actions)
|
walker = urwid.SimpleListWalker(actions)
|
||||||
super().__init__(walker)
|
super().__init__(walker)
|
||||||
|
|
||||||
def account_header(self, account):
|
|
||||||
if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"):
|
|
||||||
img = load_image(account['avatar'])
|
|
||||||
aimg = urwid.BoxAdapter(
|
|
||||||
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10)
|
|
||||||
else:
|
|
||||||
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
|
||||||
|
|
||||||
if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"):
|
|
||||||
img = load_image(account['header'])
|
|
||||||
|
|
||||||
himg = (urwid.BoxAdapter(
|
|
||||||
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10))
|
|
||||||
else:
|
|
||||||
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
|
||||||
|
|
||||||
atxt = urwid.Pile([urwid.Divider(),
|
|
||||||
(urwid.Text(("account", account["display_name"]))),
|
|
||||||
(urwid.Text(("highlight", "@" + self.account['acct'])))])
|
|
||||||
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
|
|
||||||
|
|
||||||
header = urwid.Pile([columns, urwid.Divider(), atxt])
|
|
||||||
return header
|
|
||||||
|
|
||||||
def generate_contents(self, account, relationship=None, last_action=None):
|
def generate_contents(self, account, relationship=None, last_action=None):
|
||||||
if self.last_action and not self.last_action.startswith("Confirm"):
|
if self.last_action and not self.last_action.startswith("Confirm"):
|
||||||
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
||||||
|
@ -303,11 +276,11 @@ class Account(urwid.ListBox):
|
||||||
|
|
||||||
yield urwid.Divider("─")
|
yield urwid.Divider("─")
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
|
||||||
yield self.account_header(account)
|
|
||||||
|
|
||||||
if account["note"]:
|
if account["note"]:
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
|
||||||
widgetlist = html_to_widgets(account["note"])
|
widgetlist = html_to_widgets(account["note"])
|
||||||
for line in widgetlist:
|
for line in widgetlist:
|
||||||
yield (line)
|
yield (line)
|
||||||
|
|
|
@ -1,33 +1,26 @@
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import urwid
|
import urwid
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from toot.tui import app
|
from toot.tui import app
|
||||||
|
|
||||||
from toot.tui.richtext import html_to_widgets, url_to_widget
|
from toot.tui.richtext import html_to_widgets, url_to_widget
|
||||||
from toot.utils.datetime import parse_datetime, time_ago
|
from toot.utils.datetime import parse_datetime, time_ago
|
||||||
from toot.utils.language import language_name
|
from toot.utils.language import language_name
|
||||||
|
|
||||||
from toot.entities import Status
|
from toot.entities import Status
|
||||||
from toot.tui.scroll import Scrollable, ScrollBar
|
from toot.tui.scroll import Scrollable, ScrollBar
|
||||||
|
|
||||||
from toot.tui.utils import highlight_keys
|
from toot.tui.utils import highlight_keys
|
||||||
from toot.tui.images import image_support_enabled, graphics_widget, can_render_pixels
|
|
||||||
from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
|
from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("toot")
|
logger = logging.getLogger("toot")
|
||||||
screen = urwid.raw_display.Screen()
|
|
||||||
|
|
||||||
|
|
||||||
class Timeline(urwid.Columns):
|
class Timeline(urwid.Columns):
|
||||||
"""
|
"""
|
||||||
Displays a list of statuses to the left, and status details on the right.
|
Displays a list of statuses to the left, and status details on the right.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
signals = [
|
signals = [
|
||||||
"close", # Close thread
|
"close", # Close thread
|
||||||
"focus", # Focus changed
|
"focus", # Focus changed
|
||||||
|
@ -48,7 +41,6 @@ class Timeline(urwid.Columns):
|
||||||
self.is_thread = is_thread
|
self.is_thread = is_thread
|
||||||
self.statuses = statuses
|
self.statuses = statuses
|
||||||
self.status_list = self.build_status_list(statuses, focus=focus)
|
self.status_list = self.build_status_list(statuses, focus=focus)
|
||||||
self.can_render_pixels = can_render_pixels(self.tui.options.image_format)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
focused_status = statuses[focus]
|
focused_status = statuses[focus]
|
||||||
|
@ -149,16 +141,6 @@ class Timeline(urwid.Columns):
|
||||||
def modified(self):
|
def modified(self):
|
||||||
"""Called when the list focus switches to a new status"""
|
"""Called when the list focus switches to a new status"""
|
||||||
status, index, count = self.get_focused_status_with_counts()
|
status, index, count = self.get_focused_status_with_counts()
|
||||||
|
|
||||||
if image_support_enabled:
|
|
||||||
clear_op = getattr(self.tui.screen, "clear_images", None)
|
|
||||||
# term-image's screen implementation has clear_images(),
|
|
||||||
# urwid's implementation does not.
|
|
||||||
# TODO: it would be nice not to check this each time thru
|
|
||||||
|
|
||||||
if callable(clear_op):
|
|
||||||
self.tui.screen.clear_images()
|
|
||||||
|
|
||||||
self.draw_status_details(status)
|
self.draw_status_details(status)
|
||||||
self._emit("focus")
|
self._emit("focus")
|
||||||
|
|
||||||
|
@ -300,7 +282,7 @@ class Timeline(urwid.Columns):
|
||||||
|
|
||||||
def get_status_index(self, id):
|
def get_status_index(self, id):
|
||||||
# TODO: This is suboptimal, consider a better way
|
# TODO: This is suboptimal, consider a better way
|
||||||
for n, status in enumerate(self.statuses.copy()):
|
for n, status in enumerate(self.statuses):
|
||||||
if status.id == id:
|
if status.id == id:
|
||||||
return n
|
return n
|
||||||
raise ValueError("Status with ID {} not found".format(id))
|
raise ValueError("Status with ID {} not found".format(id))
|
||||||
|
@ -324,27 +306,6 @@ class Timeline(urwid.Columns):
|
||||||
if index == self.status_list.body.focus:
|
if index == self.status_list.body.focus:
|
||||||
self.draw_status_details(status)
|
self.draw_status_details(status)
|
||||||
|
|
||||||
def update_status_image(self, status, path, placeholder_index):
|
|
||||||
"""Replace image placeholder with image widget and redraw"""
|
|
||||||
index = self.get_status_index(status.id)
|
|
||||||
assert self.statuses[index].id == status.id # Sanity check
|
|
||||||
|
|
||||||
# get the image and replace the placeholder with a graphics widget
|
|
||||||
img = None
|
|
||||||
if hasattr(self, "images"):
|
|
||||||
try:
|
|
||||||
img = self.images[(str(hash(path)))]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if img:
|
|
||||||
try:
|
|
||||||
status.placeholders[placeholder_index]._set_original_widget(
|
|
||||||
graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10))
|
|
||||||
|
|
||||||
except IndexError:
|
|
||||||
# ignore IndexErrors.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def remove_status(self, status):
|
def remove_status(self, status):
|
||||||
index = self.get_status_index(status.id)
|
index = self.get_status_index(status.id)
|
||||||
assert self.statuses[index].id == status.id # Sanity check
|
assert self.statuses[index].id == status.id # Sanity check
|
||||||
|
@ -357,9 +318,6 @@ class Timeline(urwid.Columns):
|
||||||
class StatusDetails(urwid.Pile):
|
class StatusDetails(urwid.Pile):
|
||||||
def __init__(self, timeline: Timeline, status: Optional[Status]):
|
def __init__(self, timeline: Timeline, status: Optional[Status]):
|
||||||
self.status = status
|
self.status = status
|
||||||
self.timeline = timeline
|
|
||||||
if self.status:
|
|
||||||
self.status.placeholders = []
|
|
||||||
self.followed_accounts = timeline.tui.followed_accounts
|
self.followed_accounts = timeline.tui.followed_accounts
|
||||||
self.options = timeline.tui.options
|
self.options = timeline.tui.options
|
||||||
|
|
||||||
|
@ -368,83 +326,17 @@ class StatusDetails(urwid.Pile):
|
||||||
if status else ())
|
if status else ())
|
||||||
return super().__init__(widget_list)
|
return super().__init__(widget_list)
|
||||||
|
|
||||||
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
|
|
||||||
"""Returns a widget capable of displaying the image
|
|
||||||
|
|
||||||
path is required; URL to image
|
|
||||||
rows, if specfied, sets a fixed number of rows. Or:
|
|
||||||
aspect, if specified, calculates rows based on pane width
|
|
||||||
and the aspect ratio provided"""
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
if not aspect:
|
|
||||||
aspect = 3 / 2 # reasonable default
|
|
||||||
|
|
||||||
screen_rows = screen.get_cols_rows()[1]
|
|
||||||
if self.timeline.can_render_pixels:
|
|
||||||
# for pixel-rendered images,
|
|
||||||
# image rows should be 33% of the available screen
|
|
||||||
# but in no case fewer than 10
|
|
||||||
rows = max(10, math.floor(screen_rows * .33))
|
|
||||||
else:
|
|
||||||
# for cell-rendered images,
|
|
||||||
# use the max available columns
|
|
||||||
# and calculate rows based on the image
|
|
||||||
# aspect ratio
|
|
||||||
cols = math.floor(0.55 * screen.get_cols_rows()[0])
|
|
||||||
rows = math.ceil((cols / 2) / aspect)
|
|
||||||
# if the calculated rows are more than will
|
|
||||||
# fit on one screen, reduce to one screen of rows
|
|
||||||
rows = min(screen_rows - 6, rows)
|
|
||||||
|
|
||||||
# but in no case fewer than 10 rows
|
|
||||||
rows = max(rows, 10)
|
|
||||||
|
|
||||||
img = None
|
|
||||||
if hasattr(self.timeline, "images"):
|
|
||||||
try:
|
|
||||||
img = self.timeline.images[(str(hash(path)))]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if img:
|
|
||||||
return (urwid.BoxAdapter(
|
|
||||||
graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10), rows))
|
|
||||||
else:
|
|
||||||
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
|
|
||||||
self.status.placeholders.append(placeholder)
|
|
||||||
if image_support_enabled():
|
|
||||||
self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1)
|
|
||||||
return placeholder
|
|
||||||
|
|
||||||
def author_header(self, reblogged_by):
|
|
||||||
avatar_url = self.status.original.data["account"]["avatar"]
|
|
||||||
|
|
||||||
if avatar_url and image_support_enabled():
|
|
||||||
aimg = self.image_widget(avatar_url, 2)
|
|
||||||
|
|
||||||
account_color = ("highlight" if self.status.original.author.account in
|
|
||||||
self.timeline.tui.followed_accounts else "account")
|
|
||||||
|
|
||||||
atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.original.author.display_name))),
|
|
||||||
("pack", urwid.Text((account_color, self.status.original.author.account)))])
|
|
||||||
|
|
||||||
if image_support_enabled():
|
|
||||||
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
|
|
||||||
else:
|
|
||||||
columns = urwid.Columns([("weight", 9999, atxt)], dividechars=1, min_width=5)
|
|
||||||
|
|
||||||
return columns
|
|
||||||
|
|
||||||
def content_generator(self, status, reblogged_by):
|
def content_generator(self, status, reblogged_by):
|
||||||
if reblogged_by:
|
if reblogged_by:
|
||||||
reblogger_name = (reblogged_by.display_name
|
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
||||||
if reblogged_by.display_name
|
yield ("pack", urwid.Text(("dim", text)))
|
||||||
else reblogged_by.username)
|
|
||||||
text = f"♺ {reblogger_name} boosted"
|
|
||||||
yield urwid.Text(("dim", text))
|
|
||||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||||
|
|
||||||
yield self.author_header(reblogged_by)
|
if status.author.display_name:
|
||||||
|
yield ("pack", urwid.Text(("bold", status.author.display_name)))
|
||||||
|
|
||||||
|
account_color = "highlight" if status.author.account in self.followed_accounts else "account"
|
||||||
|
yield ("pack", urwid.Text((account_color, status.author.account)))
|
||||||
yield ("pack", urwid.Divider())
|
yield ("pack", urwid.Divider())
|
||||||
|
|
||||||
if status.data["spoiler_text"]:
|
if status.data["spoiler_text"]:
|
||||||
|
@ -471,27 +363,7 @@ class StatusDetails(urwid.Pile):
|
||||||
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||||
if m["description"]:
|
if m["description"]:
|
||||||
yield ("pack", urwid.Text(m["description"]))
|
yield ("pack", urwid.Text(m["description"]))
|
||||||
if m["url"]:
|
yield ("pack", url_to_widget(m["url"]))
|
||||||
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
|
||||||
yield urwid.Text("")
|
|
||||||
try:
|
|
||||||
aspect = float(m["meta"]["original"]["aspect"])
|
|
||||||
except Exception:
|
|
||||||
aspect = None
|
|
||||||
if image_support_enabled():
|
|
||||||
yield self.image_widget(m["url"], aspect=aspect)
|
|
||||||
yield urwid.Divider()
|
|
||||||
# video media may include a preview URL, show that as a fallback
|
|
||||||
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
|
||||||
yield urwid.Text("")
|
|
||||||
try:
|
|
||||||
aspect = float(m["meta"]["small"]["aspect"])
|
|
||||||
except Exception:
|
|
||||||
aspect = None
|
|
||||||
if image_support_enabled():
|
|
||||||
yield self.image_widget(m["preview_url"], aspect=aspect)
|
|
||||||
yield urwid.Divider()
|
|
||||||
yield ("pack", url_to_widget(m["url"]))
|
|
||||||
|
|
||||||
poll = status.original.data.get("poll")
|
poll = status.original.data.get("poll")
|
||||||
if poll:
|
if poll:
|
||||||
|
@ -555,15 +427,6 @@ class StatusDetails(urwid.Pile):
|
||||||
yield urwid.Text("")
|
yield urwid.Text("")
|
||||||
yield url_to_widget(card["url"])
|
yield url_to_widget(card["url"])
|
||||||
|
|
||||||
if card["image"] and image_support_enabled():
|
|
||||||
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
|
||||||
yield urwid.Text("")
|
|
||||||
try:
|
|
||||||
aspect = int(card["width"]) / int(card["height"])
|
|
||||||
except Exception:
|
|
||||||
aspect = None
|
|
||||||
yield self.image_widget(card["image"], aspect=aspect)
|
|
||||||
|
|
||||||
def poll_generator(self, poll):
|
def poll_generator(self, poll):
|
||||||
for idx, option in enumerate(poll["options"]):
|
for idx, option in enumerate(poll["options"]):
|
||||||
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import urwid
|
import urwid
|
||||||
from collections import OrderedDict
|
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import List
|
from typing import List
|
||||||
|
@ -110,33 +109,3 @@ def deep_get(adict: dict, path: List[str], default=None):
|
||||||
path,
|
path,
|
||||||
adict
|
adict
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LRUCache(OrderedDict):
|
|
||||||
"""Dict with a limited size, ejecting LRUs as needed.
|
|
||||||
Default max size = 10Mb"""
|
|
||||||
|
|
||||||
def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs):
|
|
||||||
assert cache_max_bytes > 0
|
|
||||||
self.total_value_size = 0
|
|
||||||
self.cache_max_bytes = cache_max_bytes
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def __setitem__(self, key: str, value):
|
|
||||||
if key in self:
|
|
||||||
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
|
|
||||||
self.total_value_size += sys.getsizeof(value.tobytes())
|
|
||||||
super().__setitem__(key, value)
|
|
||||||
super().move_to_end(key)
|
|
||||||
|
|
||||||
while self.total_value_size > self.cache_max_bytes:
|
|
||||||
old_key, value = next(iter(self.items()))
|
|
||||||
sz = sys.getsizeof(value.tobytes())
|
|
||||||
super().__delitem__(old_key)
|
|
||||||
self.total_value_size -= sz
|
|
||||||
|
|
||||||
def __getitem__(self, key: str):
|
|
||||||
val = super().__getitem__(key)
|
|
||||||
super().move_to_end(key)
|
|
||||||
return val
|
|
||||||
|
|
147
toot/typing_compat.py
Normal file
147
toot/typing_compat.py
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
# Taken from https://github.com/rossmacarthur/typing-compat/
|
||||||
|
# TODO: Remove once the minimum python version is increased to 3.8
|
||||||
|
#
|
||||||
|
# Licensed under the MIT license
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
|
# copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
#
|
||||||
|
# flake8: noqa
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import typing
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['get_args', 'get_origin']
|
||||||
|
__title__ = 'typing-compat'
|
||||||
|
__version__ = '0.1.0'
|
||||||
|
__url__ = 'https://github.com/rossmacarthur/typing-compat'
|
||||||
|
__author__ = 'Ross MacArthur'
|
||||||
|
__author_email__ = 'ross@macarthur.io'
|
||||||
|
__description__ = 'Python typing compatibility library'
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python >=3.8 should have these functions already
|
||||||
|
from typing import get_args as _get_args # novermin
|
||||||
|
from typing import get_origin as _get_origin # novermin
|
||||||
|
except ImportError:
|
||||||
|
if hasattr(typing, '_GenericAlias'): # Python 3.7
|
||||||
|
|
||||||
|
def _get_origin(tp):
|
||||||
|
"""Copied from the Python 3.8 typing module"""
|
||||||
|
if isinstance(tp, typing._GenericAlias):
|
||||||
|
return tp.__origin__
|
||||||
|
if tp is typing.Generic:
|
||||||
|
return typing.Generic
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_args(tp):
|
||||||
|
"""Copied from the Python 3.8 typing module"""
|
||||||
|
if isinstance(tp, typing._GenericAlias):
|
||||||
|
res = tp.__args__
|
||||||
|
if (
|
||||||
|
get_origin(tp) is collections.abc.Callable
|
||||||
|
and res[0] is not Ellipsis
|
||||||
|
):
|
||||||
|
res = (list(res[:-1]), res[-1])
|
||||||
|
return res
|
||||||
|
return ()
|
||||||
|
|
||||||
|
else: # Python <3.7
|
||||||
|
|
||||||
|
def _resolve_via_mro(tp):
|
||||||
|
if hasattr(tp, '__mro__'):
|
||||||
|
for t in tp.__mro__:
|
||||||
|
if t.__module__ in ('builtins', '__builtin__') and t is not object:
|
||||||
|
return t
|
||||||
|
return tp
|
||||||
|
|
||||||
|
def _get_origin(tp):
|
||||||
|
"""Emulate the behaviour of Python 3.8 typing module"""
|
||||||
|
if isinstance(tp, typing._ClassVar):
|
||||||
|
return typing.ClassVar
|
||||||
|
elif isinstance(tp, typing._Union):
|
||||||
|
return typing.Union
|
||||||
|
elif isinstance(tp, typing.GenericMeta):
|
||||||
|
if hasattr(tp, '_gorg'):
|
||||||
|
return _resolve_via_mro(tp._gorg)
|
||||||
|
else:
|
||||||
|
while tp.__origin__ is not None:
|
||||||
|
tp = tp.__origin__
|
||||||
|
return _resolve_via_mro(tp)
|
||||||
|
elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal): # novermin
|
||||||
|
return typing.Literal # novermin
|
||||||
|
|
||||||
|
def _normalize_arg(args):
|
||||||
|
if isinstance(args, tuple) and len(args) > 1:
|
||||||
|
base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
|
||||||
|
if isinstance(base, typing.CallableMeta):
|
||||||
|
return typing.Callable[list(rest[:-1]), rest[-1]]
|
||||||
|
elif isinstance(base, (typing.GenericMeta, typing._Union)):
|
||||||
|
return base[rest]
|
||||||
|
return args
|
||||||
|
|
||||||
|
def _get_args(tp):
|
||||||
|
"""Emulate the behaviour of Python 3.8 typing module"""
|
||||||
|
if isinstance(tp, typing._ClassVar):
|
||||||
|
return (tp.__type__,)
|
||||||
|
elif hasattr(tp, '_subs_tree'):
|
||||||
|
tree = tp._subs_tree()
|
||||||
|
if isinstance(tree, tuple) and len(tree) > 1:
|
||||||
|
if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
|
||||||
|
return ([], _normalize_arg(tree[1]))
|
||||||
|
return tuple(_normalize_arg(arg) for arg in tree[1:])
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def get_origin(tp):
|
||||||
|
"""
|
||||||
|
Get the unsubscripted version of a type.
|
||||||
|
|
||||||
|
This supports generic types, Callable, Tuple, Union, Literal, Final and
|
||||||
|
ClassVar. Returns None for unsupported types.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
get_origin(Literal[42]) is Literal
|
||||||
|
get_origin(int) is None
|
||||||
|
get_origin(ClassVar[int]) is ClassVar
|
||||||
|
get_origin(Generic) is Generic
|
||||||
|
get_origin(Generic[T]) is Generic
|
||||||
|
get_origin(Union[T, int]) is Union
|
||||||
|
get_origin(List[Tuple[T, T]][int]) == list
|
||||||
|
"""
|
||||||
|
return _get_origin(tp)
|
||||||
|
|
||||||
|
|
||||||
|
def get_args(tp):
|
||||||
|
"""
|
||||||
|
Get type arguments with all substitutions performed.
|
||||||
|
|
||||||
|
For unions, basic simplifications used by Union constructor are performed.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
get_args(Dict[str, int]) == (str, int)
|
||||||
|
get_args(int) == ()
|
||||||
|
get_args(Union[int, Union[T, int], str][int]) == (int, str)
|
||||||
|
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
|
||||||
|
get_args(Callable[[], T][int]) == ([], int)
|
||||||
|
"""
|
||||||
|
return _get_args(tp)
|
Loading…
Reference in New Issue
Block a user