diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e4c63ef..10b3a72f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added the command palette https://github.com/Textualize/textual/pull/3058 - `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193 - Attribute `Input.validate_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193 - Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199: @@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275 - App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275 + ## [0.36.0] - 2023-09-05 ### Added diff --git a/docs/api/command_palette.md b/docs/api/command_palette.md new file mode 100644 index 000000000..c7aea72eb --- /dev/null +++ b/docs/api/command_palette.md @@ -0,0 +1,135 @@ +!!! tip "Added in version 0.37.0" + +## Introduction + +The command palette provides a system-wide facility for searching for and +executing commands. These commands are added by creating command source +classes and declaring them on your [application](../../guide/app/) or your +[screens](../../guide/screens/). + +Note that `CommandPalette` itself isn't designed to be used directly in your +applications; it is instead something that is enabled by default and is made +available by the Textual [`App`][textual.app.App] class. If you wish to +disable the availability of the command palette you can set the +[`use_command_palette`][textual.app.App.use_command_palette] switch to +`False`. + +## Creating a command source + +To add your own command source to the Textual command palette you start by +creating a class that inherits from +[`CommandSource`][textual.command_palette.CommandSource]. Your new command +source class should implement the +[`search`][textual.command_palette.CommandSource.search] method. This +should be an `async` method which `yield`s instances of +[`CommandSourceHit`][textual.command_palette.CommandSourceHit]. + +For example, suppose we wanted to create a command source that would look +through the globals in a running application and use +[`notify`][textual.app.App.notify] to show the docstring (admittedly not the +most useful command source, but illustrative of a source of text to match +and code to run). + +The command source might look something like this: + +```python +from functools import partial + +# ... + +class PythonGlobalSource(CommandSource): + """A command palette source for globals in an app.""" + + async def search(self, query: str) -> CommandMatches: + # Create a fuzzy matching object for the query. + matcher = self.matcher(query) + # Looping throught the available globals... + for name, value in globals().items(): + # Get a match score for the name. + match = matcher.match(name) + # If the match is above 0... + if match: + # ...pass the command up to the palette. + yield CommandSourceHit( + # The match score. + match, + # A highlighted version of the matched item, + # showing how and where it matched. + matcher.highlight(name), + # The code to run. Here we'll call the Textual + # notification system and get it to show the + # docstring for the chosen item, if there is + # one. + partial( + self.app.notify, + value.__doc__ or "[i]Undocumented[/i]", + title=name + ), + # The plain text that was selected. + name + ) +``` + +!!! important + + The command palette populates itself asynchronously, pulling matches from + all of the active sources. Your command source `search` method must be + `async`, and must not block in any way; doing so will affect the + performance of the user's experience while using the command palette. + +The key point here is that the `search` method should look for matches, +given the user input, and yield up a +[`CommandSourceHit`][textual.command_palette.CommandSourceHit], which will +contain the match score (which should be between 0 and 1), a Rich renderable +(such as a [rich Text object][rich.text.Text]) to illustrate how the command +was matched (this appears in the drop-down list of the command palette), a +reference to a function to run when the user selects that command, and the +plain text version of the command. + +## Unhandled exceptions in a command source + +When writing your command source `search` method you should attempt to +handle all possible errors. In the event that there is an unhandled +exception Textual will carry on working and carry on taking results from any +other registered command sources. + +!!! important + + This is different from how Textual normally works. Under normal + circumstances Textual would not "hide" your errors. + +Textual doesn't just throw the exception away though. If an exception isn't +handled by your code it will be logged to [the Textual devtools +console](../../guide/devtools#console). + +## Using a command source + +Once a command source has been created it can be used either on an `App` or +a `Screen`; this is done with the [`COMMAND_SOURCES` class variable][textual.app.App.COMMAND_SOURCES]. One or more command sources can +be given. For example: + +```python +class MyApp(App[None]): + + COMMAND_SOURCES = {MyCommandSource, MyOtherCommandSource} +``` + +When the command palette is called by the user, those sources will be used +to populate the list of search hits. + +!!! tip + + If you wish to use your own commands sources on your appliaction, and + you wish to keep using the default Textual command sources, be sure to + include the ones provided by [`App`][textual.app.App.COMMAND_SOURCES]. + For example: + + ```python + class MyApp(App[None]): + + COMMAND_SOURCES = App.COMMAND_SOURCES | {MyCommandSource, MyOtherCommandSource} + ``` + +## API documentation + +::: textual.command_palette diff --git a/docs/api/fuzzy_matcher.md b/docs/api/fuzzy_matcher.md new file mode 100644 index 000000000..015e71351 --- /dev/null +++ b/docs/api/fuzzy_matcher.md @@ -0,0 +1 @@ +::: textual._fuzzy diff --git a/docs/api/system_commands_source.md b/docs/api/system_commands_source.md new file mode 100644 index 000000000..00fe759f5 --- /dev/null +++ b/docs/api/system_commands_source.md @@ -0,0 +1 @@ +::: textual._system_commands_source diff --git a/examples/code_browser.py b/examples/code_browser.py index c613847e5..f3d482d5e 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -11,7 +11,6 @@ import sys from rich.syntax import Syntax from rich.traceback import Traceback -from textual import events from textual.app import App, ComposeResult from textual.containers import Container, VerticalScroll from textual.reactive import var @@ -43,7 +42,7 @@ class CodeBrowser(App): yield Static(id="code", expand=True) yield Footer() - def on_mount(self, event: events.Mount) -> None: + def on_mount(self) -> None: self.query_one(DirectoryTree).focus() def on_directory_tree_file_selected( diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 957765fbb..9833e9ffa 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -168,12 +168,14 @@ nav: - "api/await_remove.md" - "api/binding.md" - "api/color.md" + - "api/command_palette.md" - "api/containers.md" - "api/coordinate.md" - "api/dom_node.md" - "api/events.md" - "api/errors.md" - "api/filter.md" + - "api/fuzzy_matcher.md" - "api/geometry.md" - "api/logger.md" - "api/logging.md" @@ -189,6 +191,7 @@ nav: - "api/scroll_view.md" - "api/strip.md" - "api/suggester.md" + - "api/system_commands_source.md" - "api/timer.md" - "api/types.md" - "api/validation.md" diff --git a/poetry.lock b/poetry.lock index 54e4dfca0..85f077943 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiohttp" version = "3.8.5" description = "Async http client/server framework (asyncio)" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -115,7 +114,6 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -130,7 +128,6 @@ frozenlist = ">=1.1.0" name = "anyio" version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -153,7 +150,6 @@ trio = ["trio (<0.22)"] name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -168,7 +164,6 @@ typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} name = "asynctest" version = "0.13.0" description = "Enhance the standard unittest package with features for testing asyncio libraries" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -180,7 +175,6 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -202,7 +196,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -213,30 +206,10 @@ files = [ [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} -[[package]] -name = "beautifulsoup4" -version = "4.12.2" -description = "Screen-scraping library" -category = "dev" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, - {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "black" version = "23.3.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -287,7 +260,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "cached-property" version = "1.5.2" description = "A decorator for caching properties in classes." -category = "dev" optional = false python-versions = "*" files = [ @@ -299,7 +271,6 @@ files = [ name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -311,7 +282,6 @@ files = [ name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -323,7 +293,6 @@ files = [ name = "charset-normalizer" version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -408,7 +377,6 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -424,7 +392,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -436,7 +403,6 @@ files = [ name = "colored" version = "1.4.4" description = "Simple library for color and formatting to terminal" -category = "dev" optional = false python-versions = "*" files = [ @@ -447,7 +413,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -516,23 +481,10 @@ files = [ [package.extras] toml = ["tomli"] -[[package]] -name = "cssselect" -version = "1.2.0" -description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e"}, - {file = "cssselect-1.2.0.tar.gz", hash = "sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc"}, -] - [[package]] name = "distlib" version = "0.3.7" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -544,7 +496,6 @@ files = [ name = "exceptiongroup" version = "1.1.3" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -559,7 +510,6 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.12.2" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -575,7 +525,6 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p name = "frozenlist" version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -659,7 +608,6 @@ files = [ name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." -category = "dev" optional = false python-versions = "*" files = [ @@ -677,7 +625,6 @@ dev = ["flake8", "markdown", "twine", "wheel"] name = "gitdb" version = "4.0.10" description = "Git Object Database" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -690,14 +637,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.33" +version = "3.1.34" description = "GitPython is a Python library used to interact with Git repositories" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.33-py3-none-any.whl", hash = "sha256:11f22466f982211ad8f3bdb456c03be8466c71d4da8774f3a9f68344e89559cb"}, - {file = "GitPython-3.1.33.tar.gz", hash = "sha256:13aaa3dff88a23afec2d00eb3da3f2e040e2282e41de484c5791669b31146084"}, + {file = "GitPython-3.1.34-py3-none-any.whl", hash = "sha256:5d3802b98a3bae1c2b8ae0e1ff2e4aa16bcdf02c145da34d092324f599f01395"}, + {file = "GitPython-3.1.34.tar.gz", hash = "sha256:85f7d365d1f6bf677ae51039c1ef67ca59091c7ebd5a3509aa399d4eda02d6dd"}, ] [package.dependencies] @@ -708,7 +654,6 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\"" name = "griffe" version = "0.30.1" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -724,7 +669,6 @@ colorama = ">=0.4" name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -739,7 +683,6 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} name = "httpcore" version = "0.16.3" description = "A minimal low-level HTTP client." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -751,17 +694,16 @@ files = [ anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = ">=1.0.0,<2.0.0" +sniffio = "==1.*" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "httpx" version = "0.23.3" description = "The next generation HTTP client." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -777,15 +719,14 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<13)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "identify" version = "2.5.24" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -800,7 +741,6 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -812,7 +752,6 @@ files = [ name = "importlib-metadata" version = "6.7.0" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -833,7 +772,6 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -845,7 +783,6 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -863,7 +800,6 @@ i18n = ["Babel (>=2.7)"] name = "linkify-it-py" version = "2.0.2" description = "Links recognition library with FULL unicode support." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -880,119 +816,10 @@ dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] doc = ["myst-parser", "sphinx", "sphinx-book-theme"] test = ["coverage", "pytest", "pytest-cov"] -[[package]] -name = "lxml" -version = "4.9.3" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -files = [ - {file = "lxml-4.9.3-cp27-cp27m-macosx_11_0_x86_64.whl", hash = "sha256:b0a545b46b526d418eb91754565ba5b63b1c0b12f9bd2f808c852d9b4b2f9b5c"}, - {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:075b731ddd9e7f68ad24c635374211376aa05a281673ede86cbe1d1b3455279d"}, - {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e224d5755dba2f4a9498e150c43792392ac9b5380aa1b845f98a1618c94eeef"}, - {file = "lxml-4.9.3-cp27-cp27m-win32.whl", hash = "sha256:2c74524e179f2ad6d2a4f7caf70e2d96639c0954c943ad601a9e146c76408ed7"}, - {file = "lxml-4.9.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4f1026bc732b6a7f96369f7bfe1a4f2290fb34dce00d8644bc3036fb351a4ca1"}, - {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0781a98ff5e6586926293e59480b64ddd46282953203c76ae15dbbbf302e8bb"}, - {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cef2502e7e8a96fe5ad686d60b49e1ab03e438bd9123987994528febd569868e"}, - {file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae8b9c6deb1e634ba4f1930eb67ef6e6bf6a44b6eb5ad605642b2d6d5ed9ce3c"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"}, - {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"}, - {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1247694b26342a7bf47c02e513d32225ededd18045264d40758abeb3c838a51f"}, - {file = "lxml-4.9.3-cp310-cp310-win32.whl", hash = "sha256:cdb650fc86227eba20de1a29d4b2c1bfe139dc75a0669270033cb2ea3d391b85"}, - {file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"}, - {file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9767e79108424fb6c3edf8f81e6730666a50feb01a328f4a016464a5893f835a"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"}, - {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"}, - {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e28c51fa0ce5674be9f560c6761c1b441631901993f76700b1b30ca6c8378d6"}, - {file = "lxml-4.9.3-cp311-cp311-win32.whl", hash = "sha256:0bfd0767c5c1de2551a120673b72e5d4b628737cb05414f03c3277bf9bed3305"}, - {file = "lxml-4.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:25f32acefac14ef7bd53e4218fe93b804ef6f6b92ffdb4322bb6d49d94cad2bc"}, - {file = "lxml-4.9.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d3ff32724f98fbbbfa9f49d82852b159e9784d6094983d9a8b7f2ddaebb063d4"}, - {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48d6ed886b343d11493129e019da91d4039826794a3e3027321c56d9e71505be"}, - {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a92d3faef50658dd2c5470af249985782bf754c4e18e15afb67d3ab06233f13"}, - {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b4e4bc18382088514ebde9328da057775055940a1f2e18f6ad2d78aa0f3ec5b9"}, - {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fc9b106a1bf918db68619fdcd6d5ad4f972fdd19c01d19bdb6bf63f3589a9ec5"}, - {file = "lxml-4.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:d37017287a7adb6ab77e1c5bee9bcf9660f90ff445042b790402a654d2ad81d8"}, - {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56dc1f1ebccc656d1b3ed288f11e27172a01503fc016bcabdcbc0978b19352b7"}, - {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:578695735c5a3f51569810dfebd05dd6f888147a34f0f98d4bb27e92b76e05c2"}, - {file = "lxml-4.9.3-cp35-cp35m-win32.whl", hash = "sha256:704f61ba8c1283c71b16135caf697557f5ecf3e74d9e453233e4771d68a1f42d"}, - {file = "lxml-4.9.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c41bfca0bd3532d53d16fd34d20806d5c2b1ace22a2f2e4c0008570bf2c58833"}, - {file = "lxml-4.9.3-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:64f479d719dc9f4c813ad9bb6b28f8390360660b73b2e4beb4cb0ae7104f1c12"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:dd708cf4ee4408cf46a48b108fb9427bfa00b9b85812a9262b5c668af2533ea5"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c31c7462abdf8f2ac0577d9f05279727e698f97ecbb02f17939ea99ae8daa98"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e3cd95e10c2610c360154afdc2f1480aea394f4a4f1ea0a5eacce49640c9b190"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:4930be26af26ac545c3dffb662521d4e6268352866956672231887d18f0eaab2"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4aec80cde9197340bc353d2768e2a75f5f60bacda2bab72ab1dc499589b3878c"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14e019fd83b831b2e61baed40cab76222139926b1fb5ed0e79225bc0cae14584"}, - {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0c0850c8b02c298d3c7006b23e98249515ac57430e16a166873fc47a5d549287"}, - {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aca086dc5f9ef98c512bac8efea4483eb84abbf926eaeedf7b91479feb092458"}, - {file = "lxml-4.9.3-cp36-cp36m-win32.whl", hash = "sha256:50baa9c1c47efcaef189f31e3d00d697c6d4afda5c3cde0302d063492ff9b477"}, - {file = "lxml-4.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bef4e656f7d98aaa3486d2627e7d2df1157d7e88e7efd43a65aa5dd4714916cf"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:46f409a2d60f634fe550f7133ed30ad5321ae2e6630f13657fb9479506b00601"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4c28a9144688aef80d6ea666c809b4b0e50010a2aca784c97f5e6bf143d9f129"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:141f1d1a9b663c679dc524af3ea1773e618907e96075262726c7612c02b149a4"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:53ace1c1fd5a74ef662f844a0413446c0629d151055340e9893da958a374f70d"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17a753023436a18e27dd7769e798ce302963c236bc4114ceee5b25c18c52c693"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d298a1bd60c067ea75d9f684f5f3992c9d6766fadbc0bcedd39750bf344c2f4"}, - {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:081d32421db5df44c41b7f08a334a090a545c54ba977e47fd7cc2deece78809a"}, - {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:23eed6d7b1a3336ad92d8e39d4bfe09073c31bfe502f20ca5116b2a334f8ec02"}, - {file = "lxml-4.9.3-cp37-cp37m-win32.whl", hash = "sha256:1509dd12b773c02acd154582088820893109f6ca27ef7291b003d0e81666109f"}, - {file = "lxml-4.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:120fa9349a24c7043854c53cae8cec227e1f79195a7493e09e0c12e29f918e52"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4d2d1edbca80b510443f51afd8496be95529db04a509bc8faee49c7b0fb6d2cc"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d7e43bd40f65f7d97ad8ef5c9b1778943d02f04febef12def25f7583d19baac"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:71d66ee82e7417828af6ecd7db817913cb0cf9d4e61aa0ac1fde0583d84358db"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6fc3c450eaa0b56f815c7b62f2b7fba7266c4779adcf1cece9e6deb1de7305ce"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65299ea57d82fb91c7f019300d24050c4ddeb7c5a190e076b5f48a2b43d19c42"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eadfbbbfb41b44034a4c757fd5d70baccd43296fb894dba0295606a7cf3124aa"}, - {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3e9bdd30efde2b9ccfa9cb5768ba04fe71b018a25ea093379c857c9dad262c40"}, - {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fcdd00edfd0a3001e0181eab3e63bd5c74ad3e67152c84f93f13769a40e073a7"}, - {file = "lxml-4.9.3-cp38-cp38-win32.whl", hash = "sha256:57aba1bbdf450b726d58b2aea5fe47c7875f5afb2c4a23784ed78f19a0462574"}, - {file = "lxml-4.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:92af161ecbdb2883c4593d5ed4815ea71b31fafd7fd05789b23100d081ecac96"}, - {file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fbf521479bcac1e25a663df882c46a641a9bff6b56dc8b0fafaebd2f66fb231b"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6420a005548ad52154c8ceab4a1290ff78d757f9e5cbc68f8c77089acd3c432"}, - {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bb3bb49c7a6ad9d981d734ef7c7193bc349ac338776a0360cc671eaee89bcf69"}, - {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d27be7405547d1f958b60837dc4c1007da90b8b23f54ba1f8b728c78fdb19d50"}, - {file = "lxml-4.9.3-cp39-cp39-win32.whl", hash = "sha256:8df133a2ea5e74eef5e8fc6f19b9e085f758768a16e9877a60aec455ed2609b2"}, - {file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"}, - {file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"}, - {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"}, - {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:05186a0f1346ae12553d66df1cfce6f251589fea3ad3da4f3ef4e34b2d58c6a3"}, - {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:50670615eaf97227d5dc60de2dc99fb134a7130d310d783314e7724bf163f75d"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4da8ca0c0c0aea88fd46be8e44bd49716772358d648cce45fe387f7b92374a7"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"}, - {file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=0.29.35)"] - [[package]] name = "markdown" version = "3.4.4" description = "Python implementation of John Gruber's Markdown." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1011,7 +838,6 @@ testing = ["coverage", "pyyaml"] name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1035,28 +861,10 @@ profiling = ["gprof2dot"] rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] -[[package]] -name = "markdown2" -version = "2.4.10" -description = "A fast and complete Python implementation of Markdown" -category = "dev" -optional = false -python-versions = ">=3.5, <4" -files = [ - {file = "markdown2-2.4.10-py2.py3-none-any.whl", hash = "sha256:e6105800483783831f5dc54f827aa5b44eb137ecef5a70293d8ecfbb4109ecc6"}, - {file = "markdown2-2.4.10.tar.gz", hash = "sha256:cdba126d90dc3aef6f4070ac342f974d63f415678959329cc7909f96cc235d72"}, -] - -[package.extras] -all = ["pygments (>=2.7.3)", "wavedrom"] -code-syntax-highlighting = ["pygments (>=2.7.3)"] -wavedrom = ["wavedrom"] - [[package]] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1116,7 +924,6 @@ files = [ name = "mdit-py-plugins" version = "0.3.5" description = "Collection of plugins for markdown-it-py" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1136,7 +943,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1148,7 +954,6 @@ files = [ name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1160,7 +965,6 @@ files = [ name = "mkdocs" version = "1.5.2" description = "Project documentation with Markdown." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1193,7 +997,6 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp name = "mkdocs-autorefs" version = "0.4.1" description = "Automatically link across pages in MkDocs." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1209,7 +1012,6 @@ mkdocs = ">=1.1" name = "mkdocs-exclude" version = "1.0.2" description = "A mkdocs plugin that lets you exclude files or trees." -category = "dev" optional = false python-versions = "*" files = [ @@ -1221,36 +1023,32 @@ mkdocs = "*" [[package]] name = "mkdocs-material" -version = "9.2.6" +version = "9.2.7" description = "Documentation that simply works" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mkdocs_material-9.2.6-py3-none-any.whl", hash = "sha256:84bc7e79c1d0bae65a77123efd5ef74731b8c3671601c7962c5db8dba50a65ad"}, - {file = "mkdocs_material-9.2.6.tar.gz", hash = "sha256:3806c58dd112e7b9677225e2021035ddbe3220fbd29d9dc812aa7e01f70b5e0a"}, + {file = "mkdocs_material-9.2.7-py3-none-any.whl", hash = "sha256:92e4160d191cc76121fed14ab9f14638e43a6da0f2e9d7a9194d377f0a4e7f18"}, + {file = "mkdocs_material-9.2.7.tar.gz", hash = "sha256:b44da35b0d98cd762d09ef74f1ddce5b6d6e35c13f13beb0c9d82a629e5f229e"}, ] [package.dependencies] -babel = ">=2.10.3" -colorama = ">=0.4" -jinja2 = ">=3.0" -lxml = ">=4.6" -markdown = ">=3.2" -mkdocs = ">=1.5.2" -mkdocs-material-extensions = ">=1.1" -paginate = ">=0.5.6" -pygments = ">=2.14" -pymdown-extensions = ">=9.9.1" -readtime = ">=2.0" -regex = ">=2022.4.24" -requests = ">=2.26" +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.5,<2.0" +mkdocs-material-extensions = ">=1.1,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4,<2023.0" +requests = ">=2.26,<3.0" [[package]] name = "mkdocs-material-extensions" version = "1.1.1" description = "Extension pack for Python Markdown and MkDocs Material." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1262,7 +1060,6 @@ files = [ name = "mkdocs-rss-plugin" version = "1.5.0" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." -category = "dev" optional = false python-versions = ">=3.7, <4" files = [ @@ -1273,18 +1070,17 @@ files = [ [package.dependencies] GitPython = ">=3.1,<3.2" mkdocs = ">=1.1,<2" -pytz = {version = ">=2022.0.0,<2023.0.0", markers = "python_version < \"3.9\""} -tzdata = {version = ">=2022.0.0,<2023.0.0", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} +pytz = {version = "==2022.*", markers = "python_version < \"3.9\""} +tzdata = {version = "==2022.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} [package.extras] -dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (>=4.0.0,<4.1.0)", "validator-collection (>=1.5,<1.6)"] -doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] +dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (==4.0.*)", "validator-collection (>=1.5,<1.6)"] +doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (==0.5.*)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] [[package]] name = "mkdocstrings" version = "0.20.0" description = "Automatic documentation from sources, for MkDocs." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1310,7 +1106,6 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] name = "mkdocstrings-python" version = "0.10.1" description = "A Python handler for mkdocstrings." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1326,7 +1121,6 @@ mkdocstrings = ">=0.20" name = "msgpack" version = "1.0.5" description = "MessagePack serializer" -category = "dev" optional = false python-versions = "*" files = [ @@ -1399,7 +1193,6 @@ files = [ name = "multidict" version = "6.0.4" description = "multidict implementation" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1483,7 +1276,6 @@ files = [ name = "mypy" version = "1.4.1" description = "Optional static typing for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1531,7 +1323,6 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1543,7 +1334,6 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1558,7 +1348,6 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1570,7 +1359,6 @@ files = [ name = "paginate" version = "0.5.6" description = "Divides large result sets into pages for easier browsing" -category = "dev" optional = false python-versions = "*" files = [ @@ -1581,7 +1369,6 @@ files = [ name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1593,7 +1380,6 @@ files = [ name = "platformdirs" version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1612,7 +1398,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1631,7 +1416,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1651,7 +1435,6 @@ virtualenv = ">=20.10.0" name = "pygments" version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1666,7 +1449,6 @@ plugins = ["importlib-metadata"] name = "pymdown-extensions" version = "10.2.1" description = "Extension pack for Python Markdown." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1681,35 +1463,15 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.12)"] -[[package]] -name = "pyquery" -version = "2.0.0" -description = "A jquery-like library for python" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "pyquery-2.0.0-py3-none-any.whl", hash = "sha256:8dfc9b4b7c5f877d619bbae74b1898d5743f6ca248cfd5d72b504dd614da312f"}, - {file = "pyquery-2.0.0.tar.gz", hash = "sha256:963e8d4e90262ff6d8dec072ea97285dc374a2f69cad7776f4082abcf6a1d8ae"}, -] - -[package.dependencies] -cssselect = ">=1.2.0" -lxml = ">=2.1" - -[package.extras] -test = ["pytest", "pytest-cov", "requests", "webob", "webtest"] - [[package]] name = "pytest" -version = "7.4.0" +version = "7.4.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-7.4.1-py3-none-any.whl", hash = "sha256:460c9a59b14e27c602eb5ece2e47bec99dc5fc5f6513cf924a7d03a578991b1f"}, + {file = "pytest-7.4.1.tar.gz", hash = "sha256:2f2301e797521b23e4d2585a0a3d7b5e50fdddaaf7e7d6773ea26ddb17c213ab"}, ] [package.dependencies] @@ -1728,7 +1490,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-aiohttp" version = "1.0.4" description = "Pytest plugin for aiohttp support" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1748,7 +1509,6 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"] name = "pytest-asyncio" version = "0.21.1" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1768,7 +1528,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "pytest-cov" version = "2.12.1" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1786,14 +1545,13 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale [[package]] name = "pytest-textual-snapshot" -version = "0.2.0" +version = "0.4.0" description = "Snapshot testing for Textual apps" -category = "dev" optional = false python-versions = ">=3.6,<4.0" files = [ - {file = "pytest_textual_snapshot-0.2.0-py3-none-any.whl", hash = "sha256:663fe07bf62181ec0c63139daaeaf50eb8088164037eb30d721f028adc9edc8c"}, - {file = "pytest_textual_snapshot-0.2.0.tar.gz", hash = "sha256:5e9f8c4b1b011bdae67d4f1129530afd6611f3f8bcf03cf06699402179bc12cf"}, + {file = "pytest_textual_snapshot-0.4.0-py3-none-any.whl", hash = "sha256:879cc5de29cdd31cfe1b6daeb1dc5e42682abebcf4f88e7e3375bd5200683fc0"}, + {file = "pytest_textual_snapshot-0.4.0.tar.gz", hash = "sha256:63782e053928a925d88ff7359dd640f2900e23bc708b3007f8b388e65f2527cb"}, ] [package.dependencies] @@ -1807,7 +1565,6 @@ textual = ">=0.28.0" name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1822,7 +1579,6 @@ six = ">=1.5" name = "pytz" version = "2022.7.1" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -1834,7 +1590,6 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1884,7 +1639,6 @@ files = [ name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1895,125 +1649,107 @@ files = [ [package.dependencies] pyyaml = "*" -[[package]] -name = "readtime" -version = "3.0.0" -description = "Calculates the time some text takes the average human to read, based on Medium's read time forumula" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "readtime-3.0.0.tar.gz", hash = "sha256:76c5a0d773ad49858c53b42ba3a942f62fbe20cc8c6f07875797ac7dc30963a9"}, -] - -[package.dependencies] -beautifulsoup4 = ">=4.0.1" -markdown2 = ">=2.4.3" -pyquery = ">=1.2" - [[package]] name = "regex" -version = "2023.8.8" +version = "2022.10.31" description = "Alternative regular expression module, to replace re." -category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "regex-2023.8.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88900f521c645f784260a8d346e12a1590f79e96403971241e64c3a265c8ecdb"}, - {file = "regex-2023.8.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3611576aff55918af2697410ff0293d6071b7e00f4b09e005d614686ac4cd57c"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a0ccc8f2698f120e9e5742f4b38dc944c38744d4bdfc427616f3a163dd9de5"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c662a4cbdd6280ee56f841f14620787215a171c4e2d1744c9528bed8f5816c96"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf0633e4a1b667bfe0bb10b5e53fe0d5f34a6243ea2530eb342491f1adf4f739"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:551ad543fa19e94943c5b2cebc54c73353ffff08228ee5f3376bd27b3d5b9800"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54de2619f5ea58474f2ac211ceea6b615af2d7e4306220d4f3fe690c91988a61"}, - {file = "regex-2023.8.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5ec4b3f0aebbbe2fc0134ee30a791af522a92ad9f164858805a77442d7d18570"}, - {file = "regex-2023.8.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ae646c35cb9f820491760ac62c25b6d6b496757fda2d51be429e0e7b67ae0ab"}, - {file = "regex-2023.8.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca339088839582d01654e6f83a637a4b8194d0960477b9769d2ff2cfa0fa36d2"}, - {file = "regex-2023.8.8-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d9b6627408021452dcd0d2cdf8da0534e19d93d070bfa8b6b4176f99711e7f90"}, - {file = "regex-2023.8.8-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:bd3366aceedf274f765a3a4bc95d6cd97b130d1dda524d8f25225d14123c01db"}, - {file = "regex-2023.8.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7aed90a72fc3654fba9bc4b7f851571dcc368120432ad68b226bd593f3f6c0b7"}, - {file = "regex-2023.8.8-cp310-cp310-win32.whl", hash = "sha256:80b80b889cb767cc47f31d2b2f3dec2db8126fbcd0cff31b3925b4dc6609dcdb"}, - {file = "regex-2023.8.8-cp310-cp310-win_amd64.whl", hash = "sha256:b82edc98d107cbc7357da7a5a695901b47d6eb0420e587256ba3ad24b80b7d0b"}, - {file = "regex-2023.8.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1e7d84d64c84ad97bf06f3c8cb5e48941f135ace28f450d86af6b6512f1c9a71"}, - {file = "regex-2023.8.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce0f9fbe7d295f9922c0424a3637b88c6c472b75eafeaff6f910494a1fa719ef"}, - {file = "regex-2023.8.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06c57e14ac723b04458df5956cfb7e2d9caa6e9d353c0b4c7d5d54fcb1325c46"}, - {file = "regex-2023.8.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7a9aaa5a1267125eef22cef3b63484c3241aaec6f48949b366d26c7250e0357"}, - {file = "regex-2023.8.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7408511fca48a82a119d78a77c2f5eb1b22fe88b0d2450ed0756d194fe7a9a"}, - {file = "regex-2023.8.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14dc6f2d88192a67d708341f3085df6a4f5a0c7b03dec08d763ca2cd86e9f559"}, - {file = "regex-2023.8.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48c640b99213643d141550326f34f0502fedb1798adb3c9eb79650b1ecb2f177"}, - {file = "regex-2023.8.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0085da0f6c6393428bf0d9c08d8b1874d805bb55e17cb1dfa5ddb7cfb11140bf"}, - {file = "regex-2023.8.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:964b16dcc10c79a4a2be9f1273fcc2684a9eedb3906439720598029a797b46e6"}, - {file = "regex-2023.8.8-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7ce606c14bb195b0e5108544b540e2c5faed6843367e4ab3deb5c6aa5e681208"}, - {file = "regex-2023.8.8-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:40f029d73b10fac448c73d6eb33d57b34607f40116e9f6e9f0d32e9229b147d7"}, - {file = "regex-2023.8.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3b8e6ea6be6d64104d8e9afc34c151926f8182f84e7ac290a93925c0db004bfd"}, - {file = "regex-2023.8.8-cp311-cp311-win32.whl", hash = "sha256:942f8b1f3b223638b02df7df79140646c03938d488fbfb771824f3d05fc083a8"}, - {file = "regex-2023.8.8-cp311-cp311-win_amd64.whl", hash = "sha256:51d8ea2a3a1a8fe4f67de21b8b93757005213e8ac3917567872f2865185fa7fb"}, - {file = "regex-2023.8.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e951d1a8e9963ea51efd7f150450803e3b95db5939f994ad3d5edac2b6f6e2b4"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704f63b774218207b8ccc6c47fcef5340741e5d839d11d606f70af93ee78e4d4"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22283c769a7b01c8ac355d5be0715bf6929b6267619505e289f792b01304d898"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91129ff1bb0619bc1f4ad19485718cc623a2dc433dff95baadbf89405c7f6b57"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de35342190deb7b866ad6ba5cbcccb2d22c0487ee0cbb251efef0843d705f0d4"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b993b6f524d1e274a5062488a43e3f9f8764ee9745ccd8e8193df743dbe5ee61"}, - {file = "regex-2023.8.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3026cbcf11d79095a32d9a13bbc572a458727bd5b1ca332df4a79faecd45281c"}, - {file = "regex-2023.8.8-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:293352710172239bf579c90a9864d0df57340b6fd21272345222fb6371bf82b3"}, - {file = "regex-2023.8.8-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d909b5a3fff619dc7e48b6b1bedc2f30ec43033ba7af32f936c10839e81b9217"}, - {file = "regex-2023.8.8-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:3d370ff652323c5307d9c8e4c62efd1956fb08051b0e9210212bc51168b4ff56"}, - {file = "regex-2023.8.8-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:b076da1ed19dc37788f6a934c60adf97bd02c7eea461b73730513921a85d4235"}, - {file = "regex-2023.8.8-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e9941a4ada58f6218694f382e43fdd256e97615db9da135e77359da257a7168b"}, - {file = "regex-2023.8.8-cp36-cp36m-win32.whl", hash = "sha256:a8c65c17aed7e15a0c824cdc63a6b104dfc530f6fa8cb6ac51c437af52b481c7"}, - {file = "regex-2023.8.8-cp36-cp36m-win_amd64.whl", hash = "sha256:aadf28046e77a72f30dcc1ab185639e8de7f4104b8cb5c6dfa5d8ed860e57236"}, - {file = "regex-2023.8.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:423adfa872b4908843ac3e7a30f957f5d5282944b81ca0a3b8a7ccbbfaa06103"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ae594c66f4a7e1ea67232a0846649a7c94c188d6c071ac0210c3e86a5f92109"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e51c80c168074faa793685656c38eb7a06cbad7774c8cbc3ea05552d615393d8"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:09b7f4c66aa9d1522b06e31a54f15581c37286237208df1345108fcf4e050c18"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e73e5243af12d9cd6a9d6a45a43570dbe2e5b1cdfc862f5ae2b031e44dd95a8"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:941460db8fe3bd613db52f05259c9336f5a47ccae7d7def44cc277184030a116"}, - {file = "regex-2023.8.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f0ccf3e01afeb412a1a9993049cb160d0352dba635bbca7762b2dc722aa5742a"}, - {file = "regex-2023.8.8-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2e9216e0d2cdce7dbc9be48cb3eacb962740a09b011a116fd7af8c832ab116ca"}, - {file = "regex-2023.8.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:5cd9cd7170459b9223c5e592ac036e0704bee765706445c353d96f2890e816c8"}, - {file = "regex-2023.8.8-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4873ef92e03a4309b3ccd8281454801b291b689f6ad45ef8c3658b6fa761d7ac"}, - {file = "regex-2023.8.8-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:239c3c2a339d3b3ddd51c2daef10874410917cd2b998f043c13e2084cb191684"}, - {file = "regex-2023.8.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:1005c60ed7037be0d9dea1f9c53cc42f836188227366370867222bda4c3c6bd7"}, - {file = "regex-2023.8.8-cp37-cp37m-win32.whl", hash = "sha256:e6bd1e9b95bc5614a7a9c9c44fde9539cba1c823b43a9f7bc11266446dd568e3"}, - {file = "regex-2023.8.8-cp37-cp37m-win_amd64.whl", hash = "sha256:9a96edd79661e93327cfeac4edec72a4046e14550a1d22aa0dd2e3ca52aec921"}, - {file = "regex-2023.8.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2181c20ef18747d5f4a7ea513e09ea03bdd50884a11ce46066bb90fe4213675"}, - {file = "regex-2023.8.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a2ad5add903eb7cdde2b7c64aaca405f3957ab34f16594d2b78d53b8b1a6a7d6"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9233ac249b354c54146e392e8a451e465dd2d967fc773690811d3a8c240ac601"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:920974009fb37b20d32afcdf0227a2e707eb83fe418713f7a8b7de038b870d0b"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2b6c5dfe0929b6c23dde9624483380b170b6e34ed79054ad131b20203a1a63"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96979d753b1dc3b2169003e1854dc67bfc86edf93c01e84757927f810b8c3c93"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ae54a338191e1356253e7883d9d19f8679b6143703086245fb14d1f20196be9"}, - {file = "regex-2023.8.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2162ae2eb8b079622176a81b65d486ba50b888271302190870b8cc488587d280"}, - {file = "regex-2023.8.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c884d1a59e69e03b93cf0dfee8794c63d7de0ee8f7ffb76e5f75be8131b6400a"}, - {file = "regex-2023.8.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf9273e96f3ee2ac89ffcb17627a78f78e7516b08f94dc435844ae72576a276e"}, - {file = "regex-2023.8.8-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:83215147121e15d5f3a45d99abeed9cf1fe16869d5c233b08c56cdf75f43a504"}, - {file = "regex-2023.8.8-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f7454aa427b8ab9101f3787eb178057c5250478e39b99540cfc2b889c7d0586"}, - {file = "regex-2023.8.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0640913d2c1044d97e30d7c41728195fc37e54d190c5385eacb52115127b882"}, - {file = "regex-2023.8.8-cp38-cp38-win32.whl", hash = "sha256:0c59122ceccb905a941fb23b087b8eafc5290bf983ebcb14d2301febcbe199c7"}, - {file = "regex-2023.8.8-cp38-cp38-win_amd64.whl", hash = "sha256:c12f6f67495ea05c3d542d119d270007090bad5b843f642d418eb601ec0fa7be"}, - {file = "regex-2023.8.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82cd0a69cd28f6cc3789cc6adeb1027f79526b1ab50b1f6062bbc3a0ccb2dbc3"}, - {file = "regex-2023.8.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bb34d1605f96a245fc39790a117ac1bac8de84ab7691637b26ab2c5efb8f228c"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:987b9ac04d0b38ef4f89fbc035e84a7efad9cdd5f1e29024f9289182c8d99e09"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dd6082f4e2aec9b6a0927202c85bc1b09dcab113f97265127c1dc20e2e32495"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7eb95fe8222932c10d4436e7a6f7c99991e3fdd9f36c949eff16a69246dee2dc"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7098c524ba9f20717a56a8d551d2ed491ea89cbf37e540759ed3b776a4f8d6eb"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b694430b3f00eb02c594ff5a16db30e054c1b9589a043fe9174584c6efa8033"}, - {file = "regex-2023.8.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2aeab3895d778155054abea5238d0eb9a72e9242bd4b43f42fd911ef9a13470"}, - {file = "regex-2023.8.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:988631b9d78b546e284478c2ec15c8a85960e262e247b35ca5eaf7ee22f6050a"}, - {file = "regex-2023.8.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:67ecd894e56a0c6108ec5ab1d8fa8418ec0cff45844a855966b875d1039a2e34"}, - {file = "regex-2023.8.8-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:14898830f0a0eb67cae2bbbc787c1a7d6e34ecc06fbd39d3af5fe29a4468e2c9"}, - {file = "regex-2023.8.8-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:f2200e00b62568cfd920127782c61bc1c546062a879cdc741cfcc6976668dfcf"}, - {file = "regex-2023.8.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9691a549c19c22d26a4f3b948071e93517bdf86e41b81d8c6ac8a964bb71e5a6"}, - {file = "regex-2023.8.8-cp39-cp39-win32.whl", hash = "sha256:6ab2ed84bf0137927846b37e882745a827458689eb969028af8032b1b3dac78e"}, - {file = "regex-2023.8.8-cp39-cp39-win_amd64.whl", hash = "sha256:5543c055d8ec7801901e1193a51570643d6a6ab8751b1f7dd9af71af467538bb"}, - {file = "regex-2023.8.8.tar.gz", hash = "sha256:fcbdc5f2b0f1cd0f6a56cdb46fe41d2cce1e644e3b68832f3eeebc5fb0f7712e"}, + {file = "regex-2022.10.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f"}, + {file = "regex-2022.10.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90"}, + {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc"}, + {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66"}, + {file = "regex-2022.10.31-cp310-cp310-win32.whl", hash = "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1"}, + {file = "regex-2022.10.31-cp310-cp310-win_amd64.whl", hash = "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5"}, + {file = "regex-2022.10.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe"}, + {file = "regex-2022.10.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1"}, + {file = "regex-2022.10.31-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c"}, + {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7"}, + {file = "regex-2022.10.31-cp311-cp311-win32.whl", hash = "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af"}, + {file = "regex-2022.10.31-cp311-cp311-win_amd64.whl", hash = "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61"}, + {file = "regex-2022.10.31-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"}, + {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e"}, + {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4"}, + {file = "regex-2022.10.31-cp36-cp36m-win32.whl", hash = "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066"}, + {file = "regex-2022.10.31-cp36-cp36m-win_amd64.whl", hash = "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6"}, + {file = "regex-2022.10.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11"}, + {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5"}, + {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95"}, + {file = "regex-2022.10.31-cp37-cp37m-win32.whl", hash = "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394"}, + {file = "regex-2022.10.31-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0"}, + {file = "regex-2022.10.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d"}, + {file = "regex-2022.10.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6"}, + {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d"}, + {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c"}, + {file = "regex-2022.10.31-cp38-cp38-win32.whl", hash = "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc"}, + {file = "regex-2022.10.31-cp38-cp38-win_amd64.whl", hash = "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453"}, + {file = "regex-2022.10.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49"}, + {file = "regex-2022.10.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7"}, + {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8"}, + {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892"}, + {file = "regex-2022.10.31-cp39-cp39-win32.whl", hash = "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1"}, + {file = "regex-2022.10.31-cp39-cp39-win_amd64.whl", hash = "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692"}, + {file = "regex-2022.10.31.tar.gz", hash = "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83"}, ] [[package]] name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2035,7 +1771,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rfc3986" version = "1.5.0" description = "Validating URI References per RFC 3986" -category = "dev" optional = false python-versions = "*" files = [ @@ -2053,7 +1788,6 @@ idna2008 = ["idna"] name = "rich" version = "13.5.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -2073,7 +1807,6 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2090,7 +1823,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2102,7 +1834,6 @@ files = [ name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2114,7 +1845,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2122,23 +1852,10 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] -[[package]] -name = "soupsieve" -version = "2.4.1" -description = "A modern CSS selector implementation for Beautiful Soup." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "soupsieve-2.4.1-py3-none-any.whl", hash = "sha256:1c1bfee6819544a3447586c889157365a27e10d88cde3ad3da0cf0ddf646feb8"}, - {file = "soupsieve-2.4.1.tar.gz", hash = "sha256:89d12b2d5dfcd2c9e8c22326da9d9aa9cb3dfab0a83a024f05704076ee8d35ea"}, -] - [[package]] name = "syrupy" version = "3.0.6" description = "Pytest Snapshot Test Utility" -category = "dev" optional = false python-versions = ">=3.7,<4" files = [ @@ -2154,7 +1871,6 @@ pytest = ">=5.1.0,<8.0.0" name = "textual-dev" version = "1.1.0" description = "Development tools for working with Textual" -category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -2173,7 +1889,6 @@ typing-extensions = ">=4.4.0,<5.0.0" name = "time-machine" version = "2.10.0" description = "Travel through time in your tests." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2240,7 +1955,6 @@ python-dateutil = "*" name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2252,7 +1966,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2264,7 +1977,6 @@ files = [ name = "typed-ast" version = "1.5.5" description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2315,7 +2027,6 @@ files = [ name = "types-setuptools" version = "67.8.0.0" description = "Typing stubs for setuptools" -category = "dev" optional = false python-versions = "*" files = [ @@ -2327,7 +2038,6 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2339,7 +2049,6 @@ files = [ name = "tzdata" version = "2022.7" description = "Provider of IANA time zone data" -category = "dev" optional = false python-versions = ">=2" files = [ @@ -2351,7 +2060,6 @@ files = [ name = "uc-micro-py" version = "1.0.2" description = "Micro subset of unicode data files for linkify-it-py projects." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2366,7 +2074,6 @@ test = ["coverage", "pytest", "pytest-cov"] name = "urllib3" version = "2.0.4" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2384,7 +2091,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "virtualenv" version = "20.24.4" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2406,7 +2112,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "watchdog" version = "3.0.0" description = "Filesystem events monitoring" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2446,7 +2151,6 @@ watchmedo = ["PyYAML (>=3.10)"] name = "yarl" version = "1.9.2" description = "Yet another URL library" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2535,7 +2239,6 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2550,4 +2253,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "5ac8aef69083d16bc38af16f22cc94ad14b8b70b5cff61e0c7d462c1d1a8a42c" +content-hash = "3817b3d8b678845abb17cddd49d5a6ea5fb9d0083faa356ef232184a94312ba6" diff --git a/pyproject.toml b/pyproject.toml index 01c053476..0b6ea079c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ httpx = "^0.23.1" types-setuptools = "^67.2.0.1" textual-dev = "^1.1.0" pytest-asyncio = "*" -pytest-textual-snapshot = "0.2.0" +pytest-textual-snapshot = "*" [tool.black] includes = "src" diff --git a/src/textual/_fuzzy.py b/src/textual/_fuzzy.py index 2d9766e05..3fa4b0094 100644 --- a/src/textual/_fuzzy.py +++ b/src/textual/_fuzzy.py @@ -1,6 +1,9 @@ -from re import compile, escape +from __future__ import annotations + +from re import IGNORECASE, compile, escape import rich.repr +from rich.style import Style from rich.text import Text from ._cache import LRUCache @@ -10,29 +13,61 @@ from ._cache import LRUCache class Matcher: """A fuzzy matcher.""" - def __init__(self, query: str) -> None: - """ + def __init__( + self, + query: str, + *, + match_style: Style | None = None, + case_sensitive: bool = False, + ) -> None: + """Initialise the fuzzy matching object. + Args: query: A query as typed in by the user. + match_style: The style to use to highlight matched portions of a string. + case_sensitive: Should matching be case sensitive? """ - self.query = query - self._query_regex = ".*?".join(f"({escape(character)})" for character in query) - self._query_regex_compiled = compile(self._query_regex) + self._query = query + self._match_style = Style(reverse=True) if match_style is None else match_style + self._query_regex = compile( + ".*?".join(f"({escape(character)})" for character in query), + flags=0 if case_sensitive else IGNORECASE, + ) self._cache: LRUCache[str, float] = LRUCache(1024 * 4) - def match(self, input: str) -> float: - """Match the input against the query + @property + def query(self) -> str: + """The query string to look for.""" + return self._query + + @property + def match_style(self) -> Style: + """The style that will be used to highlight hits in the matched text.""" + return self._match_style + + @property + def query_pattern(self) -> str: + """The regular expression pattern built from the query.""" + return self._query_regex.pattern + + @property + def case_sensitive(self) -> bool: + """Is this matcher case sensitive?""" + return not bool(self._query_regex.flags & IGNORECASE) + + def match(self, candidate: str) -> float: + """Match the candidate against the query. Args: - input: Input string to match against. + candidate: Candidate string to match against the query. Returns: Strength of the match from 0 to 1. """ - cached = self._cache.get(input) + cached = self._cache.get(candidate) if cached is not None: return cached - match = self._query_regex_compiled.search(input) + match = self._query_regex.search(candidate) if match is None: score = 0.0 else: @@ -47,21 +82,21 @@ class Matcher: group_count += 1 last_offset = offset - score = 1.0 - ((group_count - 1) / len(input)) - self._cache[input] = score + score = 1.0 - ((group_count - 1) / len(candidate)) + self._cache[candidate] = score return score - def highlight(self, input: str) -> Text: - """Highlight the input with the fuzzy match. + def highlight(self, candidate: str) -> Text: + """Highlight the candidate with the fuzzy match. Args: - input: User input. + candidate: The candidate string to match against the query. Returns: - A Text object with matched letters in bold. + A [rich.text.Text][`Text`] object with highlighted matches. """ - match = self._query_regex_compiled.search(input) - text = Text(input) + match = self._query_regex.search(candidate) + text = Text(candidate) if match is None: return text assert match.lastindex is not None @@ -69,14 +104,55 @@ class Matcher: match.span(group_no)[0] for group_no in range(1, match.lastindex + 1) ] for offset in offsets: - text.stylize("bold", offset, offset + 1) + text.stylize(self._match_style, offset, offset + 1) return text if __name__ == "__main__": + from itertools import permutations + from string import ascii_lowercase + from time import monotonic + from rich import print + from rich.rule import Rule matcher = Matcher("foo.bar") - print(matcher.match("xz foo.bar sdf")) - print(matcher.highlight("xz foo.bar sdf")) + print(Rule()) + print("Query is:", matcher.query) + print("Rule is:", matcher.query_pattern) + print(Rule()) + candidates = ( + "foo.bar", + " foo.bar ", + "Hello foo.bar world", + "f o o . b a r", + "f o o .bar", + "foo. b a r", + "Lots of text before the foo.bar", + "foo.bar up front and then lots of text afterwards", + "This has an o in it but does not have a match", + "Let's find one obvious match. But blat around always roughly.", + ) + results = sorted( + [ + (matcher.match(candidate), matcher.highlight(candidate)) + for candidate in candidates + ], + key=lambda pair: pair[0], + reverse=True, + ) + for score, highlight in results: + print(f"{score:.15f} '", highlight, "'", sep="") + print(Rule()) + + RUNS = 5 + candidates = [ + "".join(permutation) for permutation in permutations(ascii_lowercase[:10]) + ] + matcher = Matcher(ascii_lowercase[:10]) + start = monotonic() + for _ in range(RUNS): + for candidate in candidates: + _ = matcher.match(candidate) + print(f"{RUNS * len(candidates)} matches in {monotonic() - start:.5f} seconds") diff --git a/src/textual/_system_commands_source.py b/src/textual/_system_commands_source.py new file mode 100644 index 000000000..deacd7788 --- /dev/null +++ b/src/textual/_system_commands_source.py @@ -0,0 +1,56 @@ +"""A command palette command source for Textual system commands. + +This is a simple command source that makes the most obvious application +actions available via the [command palette][textual.command_palette.CommandPalette]. +""" + +from .command_palette import CommandMatches, CommandSource, CommandSourceHit + + +class SystemCommandSource(CommandSource): + """A [source][textual.command_palette.CommandSource] of command palette commands that run app-wide tasks. + + Used by default in [`App.COMMAND_SOURCES`][textual.app.App.COMMAND_SOURCES]. + """ + + async def search(self, query: str) -> CommandMatches: + """Handle a request to search for system commands that match the query. + + Args: + user_input: The user input to be matched. + + Yields: + Command source hits for use in the command palette. + """ + # We're going to use Textual's builtin fuzzy matcher to find + # matching commands. + matcher = self.matcher(query) + + # Loop over all applicable commands, find those that match and offer + # them up to the command palette. + for name, runnable, help_text in ( + ( + "Toggle light/dark mode", + self.app.action_toggle_dark, + "Toggle the application between light and dark mode", + ), + ( + "Quit the application", + self.app.action_quit, + "Quit the application as soon as possible", + ), + ( + "Ring the bell", + self.app.action_bell, + "Ring the terminal's 'bell'", + ), + ): + match = matcher.match(name) + if match > 0: + yield CommandSourceHit( + match, + matcher.highlight(name), + runnable, + name, + help_text, + ) diff --git a/src/textual/app.py b/src/textual/app.py index e04d42d5e..2991f51e2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -68,11 +68,13 @@ from ._context import active_app, active_message_pump from ._context import message_hook as message_hook_context_var from ._event_broker import NoHandler, extract_handler_actions from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative +from ._system_commands_source import SystemCommandSource from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings +from .command_palette import CommandPalette, CommandPaletteCallable, CommandSource from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem @@ -323,8 +325,23 @@ class App(Generic[ReturnType], DOMNode): See also [the `Screen.SUB_TITLE` attribute][textual.screen.Screen.SUB_TITLE]. """ + ENABLE_COMMAND_PALETTE: ClassVar[bool] = True + """Should the [command palette][textual.command_palette.CommandPalette] be enabled for the application?""" + + COMMAND_SOURCES: ClassVar[set[type[CommandSource]]] = {SystemCommandSource} + """The [command sources](/api/command_palette/) for the application. + + This is the collection of [command sources][textual.command_palette.CommandSource] + that provide matched + commands to the [command palette][textual.command_palette.CommandPalette]. + + The default Textual command palette source is + [the Textual system-wide command source][textual._system_commands_source.SystemCommandSource]. + """ + BINDINGS: ClassVar[list[BindingType]] = [ - Binding("ctrl+c", "quit", "Quit", show=False, priority=True) + Binding("ctrl+c", "quit", "Quit", show=False, priority=True), + Binding("ctrl+@", "command_palette", show=False, priority=True), ] title: Reactive[str] = Reactive("", compute=False) @@ -438,6 +455,14 @@ class App(Generic[ReturnType], DOMNode): The new value is always converted to string. """ + self.use_command_palette: bool = self.ENABLE_COMMAND_PALETTE + """A flag to say if the application should use the command palette. + + If set to `False` any call to + [`action_command_palette`][textual.app.App.action_command_palette] + will be ignored. + """ + self._logger = Logger(self._log) self._refresh_required = False @@ -3003,3 +3028,8 @@ class App(Generic[ReturnType], DOMNode): """Clear all the current notifications.""" self._notifications.clear() self._refresh_notifications() + + def action_command_palette(self) -> None: + """Show the Textual command palette.""" + if self.use_command_palette and not CommandPalette.is_open(self): + self.push_screen(CommandPalette(), callback=self.call_next) diff --git a/src/textual/command_palette.py b/src/textual/command_palette.py new file mode 100644 index 000000000..e1201a7c4 --- /dev/null +++ b/src/textual/command_palette.py @@ -0,0 +1,892 @@ +"""The Textual command palette.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from asyncio import CancelledError, Queue, TimeoutError, wait_for +from functools import total_ordering +from time import monotonic +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + AsyncIterator, + Callable, + ClassVar, + NamedTuple, +) + +from rich.align import Align +from rich.console import Group, RenderableType +from rich.emoji import Emoji +from rich.style import Style +from rich.text import Text +from rich.traceback import Traceback +from typing_extensions import Final, TypeAlias + +from . import on, work +from ._asyncio import create_task +from ._fuzzy import Matcher +from .binding import Binding, BindingType +from .containers import Horizontal, Vertical +from .events import Click, Mount +from .reactive import var +from .screen import ModalScreen, Screen +from .timer import Timer +from .widget import Widget +from .widgets import Button, Input, LoadingIndicator, OptionList, Static +from .widgets.option_list import Option +from .worker import get_current_worker + +if TYPE_CHECKING: + from .app import App, ComposeResult + +__all__ = [ + "CommandMatches", + "CommandPalette", + "CommandPaletteCallable", + "CommandSource", + "CommandSourceHit", + "Matcher", +] + + +CommandPaletteCallable: TypeAlias = Callable[[], Any] +"""The type of a function that will be called when a command is selected from the command palette.""" + + +@total_ordering +class CommandSourceHit(NamedTuple): + """Holds the details of a single command search hit.""" + + match_value: float + """The match value of the command hit. + + The value should be between 0 (no match) and 1 (complete match). + """ + + match_display: RenderableType + """The Rich renderable representation of the hit. + + Ideally a [rich Text object][rich.text.Text] object or similar. + """ + + command: CommandPaletteCallable + """The function to call when the command is chosen.""" + + command_text: str + """The command text associated with the hit, as plain text. + + This is the text that will be placed into the `Input` field of the + [command palette][textual.command_palette.CommandPalette] when a + selection is made. + """ + + command_help: str | None = None + """Optional help text for the command.""" + + def __lt__(self, other: object) -> bool: + if isinstance(other, CommandSourceHit): + return self.match_value < other.match_value + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, CommandSourceHit): + return self.match_value == other.match_value + return NotImplemented + + +CommandMatches: TypeAlias = AsyncIterator[CommandSourceHit] +"""Return type for the command source match searching method.""" + + +class CommandSource(ABC): + """Base class for command palette command sources. + + To create a source of commands inherit from this class and implement + [`search`][textual.command_palette.CommandSource.search]. + """ + + def __init__(self, screen: Screen[Any], match_style: Style | None = None) -> None: + """Initialise the command source. + + Args: + screen: A reference to the active screen. + """ + self.__screen = screen + self.__match_style = match_style + + @property + def focused(self) -> Widget | None: + """The currently-focused widget in the currently-active screen in the application. + + If no widget has focus this will be `None`. + """ + return self.__screen.focused + + @property + def screen(self) -> Screen[object]: + """The currently-active screen in the application.""" + return self.__screen + + @property + def app(self) -> App[object]: + """A reference to the application.""" + return self.__screen.app + + @property + def match_style(self) -> Style | None: + """The preferred style to use when highlighting matching portions of the [`match_display`][textual.command_palette.CommandSourceHit.match_display].""" + return self.__match_style + + def matcher(self, user_input: str, case_sensitive: bool = False) -> Matcher: + """Create a [fuzzy matcher][textual._fuzzy.Matcher] for the given user input. + + Args: + user_input: The text that the user has input. + case_sensitive: Should match be case sensitive? + + Returns: + A [fuzzy matcher][textual._fuzzy.Matcher] object for matching against candidate hits. + """ + return Matcher( + user_input, match_style=self.match_style, case_sensitive=case_sensitive + ) + + @abstractmethod + async def search(self, query: str) -> CommandMatches: + """A request to search for commands relevant to the given query. + + Args: + query: The user input to be matched. + + Yields: + Instances of [`CommandSourceHit`][textual.command_palette.CommandSourceHit]. + """ + yield NotImplemented + + +@total_ordering +class Command(Option): + """Class that holds a command in the [`CommandList`][textual.command_palette.CommandList].""" + + def __init__( + self, + prompt: RenderableType, + command: CommandSourceHit, + id: str | None = None, + disabled: bool = False, + ) -> None: + """Initialise the option. + + Args: + prompt: The prompt for the option. + command: The details of the command associated with the option. + id: The optional ID for the option. + disabled: The initial enabled/disabled state. Enabled by default. + """ + super().__init__(prompt, id, disabled) + self.command = command + """The details of the command associated with the option.""" + + def __lt__(self, other: object) -> bool: + if isinstance(other, Command): + return self.command < other.command + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Command): + return self.command == other.command + return NotImplemented + + +class CommandList(OptionList, can_focus=False): + """The command palette command list.""" + + DEFAULT_CSS = """ + CommandList { + visibility: hidden; + border-top: blank; + border-bottom: hkey $accent; + border-left: none; + border-right: none; + height: auto; + max-height: 70vh; + background: $panel; + } + + CommandList:focus { + border: blank; + } + + CommandList.--visible { + visibility: visible; + } + + CommandList.--populating { + border-bottom: none; + } + + CommandList > .option-list--option-highlighted { + background: $accent; + } + + CommandList > .option-list--option { + padding-left: 1; + } + """ + + +class SearchIcon(Static, inherit_css=False): + """Widget for displaying a search icon before the command input.""" + + DEFAULT_CSS = """ + SearchIcon { + margin-left: 1; + margin-top: 1; + width: 2; + } + """ + + icon: var[str] = var(Emoji.replace(":magnifying_glass_tilted_right:")) + """The icon to display.""" + + def render(self) -> RenderableType: + """Render the icon. + + Returns: + The icon renderable. + """ + return self.icon + + +class CommandInput(Input): + """The command palette input control.""" + + DEFAULT_CSS = """ + CommandInput, CommandInput:focus { + border: blank; + width: 1fr; + background: $panel; + padding-left: 0; + } + """ + + +class CommandPalette(ModalScreen[CommandPaletteCallable], inherit_css=False): + """The Textual command palette.""" + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "command-palette--help-text", + "command-palette--highlight", + } + """ + | Class | Description | + | :- | :- | + | `command-palette--help-text` | Targets the help text of a matched command. | + | `command-palette--highlight` | Targets the highlights of a matched command. | + """ + + DEFAULT_CSS = """ + CommandPalette { + background: $background 30%; + align-horizontal: center; + } + + CommandPalette > .command-palette--help-text { + text-style: dim; + background: transparent; + } + + CommandPalette > .command-palette--highlight { + text-style: bold reverse; + } + + CommandPalette > Vertical { + margin-top: 3; + width: 90%; + height: 100%; + visibility: hidden; + } + + CommandPalette #--input { + height: auto; + visibility: visible; + border: hkey $accent; + background: $panel; + } + + CommandPalette #--input.--list-visible { + border-bottom: none; + } + + CommandPalette #--input Label { + margin-top: 1; + margin-left: 1; + } + + CommandPalette #--input Button { + min-width: 7; + margin-right: 1; + } + + CommandPalette #--results { + overlay: screen; + height: auto; + } + + CommandPalette LoadingIndicator { + height: auto; + visibility: hidden; + background: $panel; + border-bottom: hkey $accent; + } + + CommandPalette LoadingIndicator.--visible { + visibility: visible; + } + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+end, shift+end", "command_list('last')", show=False), + Binding("ctrl+home, shift+home", "command_list('first')", show=False), + Binding("down", "cursor_down", show=False), + Binding("escape", "escape", "Exit the command palette"), + Binding("pagedown", "command_list('page_down')", show=False), + Binding("pageup", "command_list('page_up')", show=False), + Binding("up", "command_list('cursor_up')", show=False), + ] + """ + | Key(s) | Description | + | :- | :- | + | ctrl+end, shift+end | Jump to the last available commands. | + | ctrl+home, shift+home | Jump to the first available commands. | + | down | Navigate down through the available commands. | + | escape | Exit the command palette. | + | pagedown | Navigate down a page through the available commands. | + | pageup | Navigate up a page through the available commands. | + | up | Navigate up through the available commands. | + """ + + run_on_select: ClassVar[bool] = True + """A flag to say if a command should be run when selected by the user. + + If `True` then when a user hits `Enter` on a command match in the result + list, or if they click on one with the mouse, the command will be + selected and run. If set to `False` the input will be filled with the + command and then `Enter` should be pressed on the keyboard or the 'go' + button should be pressed. + """ + + _list_visible: var[bool] = var(False, init=False) + """Internal reactive to toggle the visibility of the command list.""" + + _show_busy: var[bool] = var(False, init=False) + """Internal reactive to toggle the visibility of the busy indicator.""" + + _calling_screen: var[Screen[Any] | None] = var(None) + """A record of the screen that was active when we were called.""" + + _PALETTE_ID: Final[str] = "--command-palette" + """The internal ID for the command palette.""" + + def __init__(self) -> None: + """Initialise the command palette.""" + super().__init__(id=self._PALETTE_ID) + self._selected_command: CommandSourceHit | None = None + """The command that was selected by the user.""" + self._busy_timer: Timer | None = None + """Keeps track of if there's a busy indication timer in effect.""" + + @staticmethod + def is_open(app: App) -> bool: + """Is the command palette current open? + + Args: + app: The app to test. + + Returns: + `True` if the command palette is currently open, `False` if not. + """ + return app.screen.id == CommandPalette._PALETTE_ID + + @property + def _sources(self) -> set[type[CommandSource]]: + """The currently available command sources. + + This is a combination of the command sources defined [in the + application][textual.app.App.COMMAND_SOURCES] and those [defined in + the current screen][textual.screen.Screen.COMMAND_SOURCES]. + """ + return ( + set() + if self._calling_screen is None + else self.app.COMMAND_SOURCES | self._calling_screen.COMMAND_SOURCES + ) + + def compose(self) -> ComposeResult: + """Compose the command palette. + + Returns: + The content of the screen. + """ + with Vertical(): + with Horizontal(id="--input"): + yield SearchIcon() + yield CommandInput(placeholder="Search...") + if not self.run_on_select: + yield Button("\u25b6") + with Vertical(id="--results"): + yield CommandList() + yield LoadingIndicator() + + def _on_click(self, event: Click) -> None: + """Handle the click event. + + Args: + event: The click event. + + This method is used to allow clicking on the 'background' as a + method of dismissing the palette. + """ + if self.get_widget_at(event.screen_x, event.screen_y)[0] is self: + self.workers.cancel_all() + self.dismiss() + + def _on_mount(self, _: Mount) -> None: + """Capture the calling screen.""" + self._calling_screen = self.app.screen_stack[-2] + + def _stop_busy_countdown(self) -> None: + """Stop any busy countdown that's in effect.""" + if self._busy_timer is not None: + self._busy_timer.stop() + self._busy_timer = None + + _BUSY_COUNTDOWN: Final[float] = 0.5 + """How many seconds to wait for commands to come in before showing we're busy.""" + + def _start_busy_countdown(self) -> None: + """Start a countdown to showing that we're busy searching.""" + self._stop_busy_countdown() + + def _become_busy() -> None: + if self._list_visible: + self._show_busy = True + + self._busy_timer = self._busy_timer = self.set_timer( + self._BUSY_COUNTDOWN, _become_busy + ) + + def _watch__list_visible(self) -> None: + """React to the list visible flag being toggled.""" + self.query_one(CommandList).set_class(self._list_visible, "--visible") + self.query_one("#--input", Horizontal).set_class( + self._list_visible, "--list-visible" + ) + if not self._list_visible: + self._show_busy = False + + async def _watch__show_busy(self) -> None: + """React to the show busy flag being toggled. + + This watcher adds or removes a busy indication depending on the + flag's state. + """ + self.query_one(LoadingIndicator).set_class(self._show_busy, "--visible") + self.query_one(CommandList).set_class(self._show_busy, "--populating") + + @staticmethod + async def _consume( + source: CommandMatches, commands: Queue[CommandSourceHit] + ) -> None: + """Consume a source of matching commands, feeding the given command queue. + + Args: + source: The source to consume. + commands: The command queue to feed. + """ + async for hit in source: + await commands.put(hit) + + async def _search_for( + self, search_value: str + ) -> AsyncGenerator[CommandSourceHit, bool]: + """Search for a given search value amongst all of the command sources. + + Args: + search_value: The value to search for. + + Yields: + The hits made amongst the registered command sources. + """ + + # Get the style for highlighted parts of a hit match. + match_style = self._sans_background( + self.get_component_rich_style("command-palette--highlight") + ) + + # Set up a queue to stream in the command hits from all the sources. + commands: Queue[CommandSourceHit] = Queue() + + # Fire up an instance of each command source, inside a task, and + # have them go start looking for matches. + assert self._calling_screen is not None + searches = [ + create_task( + self._consume( + source(self._calling_screen, match_style).search(search_value), + commands, + ) + ) + for source in self._sources + ] + + # Set up a delay for showing that we're busy. + self._start_busy_countdown() + + # Assume the search isn't aborted. + aborted = False + + # Now, while there's some task running... + while not aborted and any(not search.done() for search in searches): + try: + # ...briefly wait for something on the stack. If we get + # something yield it up to our caller. + aborted = yield await wait_for(commands.get(), 0.1) + except TimeoutError: + # A timeout is fine. We're just going to go back round again + # and see if anything else has turned up. + pass + except CancelledError: + # A cancelled error means things are being aborted. + aborted = True + else: + # There was no timeout, which means that we managed to yield + # up that command; we're done with it so let the queue know. + commands.task_done() + + # Check through all the finished searches, see if any have + # exceptions, and log them. In most other circumstances we'd + # re-raise the exception and quit the application, but the decision + # has been made to find and log exceptions with command sources. + # + # https://github.com/Textualize/textual/pull/3058#discussion_r1310051855 + for search in searches: + if search.done(): + exception = search.exception() + if exception is not None: + self.log.error( + Traceback.from_exception( + type(exception), exception, exception.__traceback__ + ) + ) + + # Having finished the main processing loop, we're not busy any more. + # Anything left in the queue (see next) will fall out more or less + # instantly. + self._stop_busy_countdown() + + # If all the sources are pretty fast it could be that we've reached + # this point but the queue isn't empty yet. So here we flush the + # queue of anything left. + while not aborted and not commands.empty(): + aborted = yield await commands.get() + + # If we were aborted, ensure that all of the searches are cancelled. + if aborted: + for search in searches: + search.cancel() + + @staticmethod + def _sans_background(style: Style) -> Style: + """Returns the given style minus the background color. + + Args: + style: The style to remove the color from. + + Returns: + The given style, minus its background. + """ + # Here we're pulling out all of the styles *minus* the background. + # This should probably turn into a utility method on Style + # eventually. The reason for this is we want the developer to be + # able to style the help text with a component class, but we want + # the background to always be the background at any given moment in + # the context of an OptionList. At the moment this act of copying + # sans bgcolor seems to be the only way to achieve this. + return Style( + blink2=style.blink2, + blink=style.blink, + bold=style.bold, + color=style.color, + conceal=style.conceal, + dim=style.dim, + encircle=style.encircle, + frame=style.frame, + italic=style.italic, + link=style.link, + overline=style.overline, + reverse=style.reverse, + strike=style.strike, + underline2=style.underline2, + underline=style.underline, + ) + + def _refresh_command_list( + self, command_list: CommandList, commands: list[Command], clear_current: bool + ) -> None: + """Refresh the command list. + + Args: + command_list: The widget that shows the list of commands. + commands: The commands to show in the widget. + clear_current: Should the current content of the list be cleared first? + """ + # For the moment, this is a fairly naive approach to populating the + # command list with a sorted list of commands. Every time we add a + # new one we're nuking the list of options and populating them + # again. If this turns out to not be a great approach, we may try + # and get a lot smarter with this (ideally OptionList will grow a + # method to sort its content in an efficient way; but for now we'll + # go with "worse is better" wisdom). + highlighted = ( + command_list.get_option_at_index(command_list.highlighted) + if command_list.highlighted is not None and not clear_current + else None + ) + command_list.clear_options().add_options(sorted(commands, reverse=True)) + if highlighted is not None: + command_list.highlighted = command_list.get_option_index(highlighted.id) + + _RESULT_BATCH_TIME: Final[float] = 0.25 + """How long to wait before adding commands to the command list.""" + + _NO_MATCHES: Final[str] = "--no-matches" + """The ID to give the disabled option that shows there were no matches.""" + + @work(exclusive=True) + async def _gather_commands(self, search_value: str) -> None: + """Gather up all of the commands that match the search value. + + Args: + search_value: The value to search for. + """ + + # We'll potentially use the help text style a lot so let's grab it + # the once for use in the loop further down. + help_style = self._sans_background( + self.get_component_rich_style("command-palette--help-text") + ) + + # The list to hold on to the commands we've gathered from the + # command sources. + gathered_commands: list[Command] = [] + + # Get a reference to the widget that we're going to drop the + # (display of) commands into. + command_list = self.query_one(CommandList) + + # If there's just one option in the list, and it's the item that + # tells the user there were no matches, let's remove that. We're + # starting a new search so we don't want them thinking there's no + # matches already. + if ( + command_list.option_count == 1 + and command_list.get_option_at_index(0).id == self._NO_MATCHES + ): + command_list.remove_option(self._NO_MATCHES) + + # Each command will receive a sequential ID. This is going to be + # used to find commands back again when we update the visible list + # and want to settle the selection back on the command it was on. + command_id = 0 + + # We're going to be checking in on the worker as we loop around, so + # grab a reference to that. + worker = get_current_worker() + + # We're ready to show results, ensure the list is visible. + self._list_visible = True + + # Go into a busy mode. + self._show_busy = False + + # A flag to keep track of if the current content of the command hit + # list needs to be cleared. The initial clear *should* be in + # `_input`, but doing so caused an unsightly "flash" of the list; so + # here we sacrifice "correct" code for a better-looking UI. + clear_current = True + + # We're going to batch updates over time, so start off pretending + # we've just done an update. + last_update = monotonic() + + # Kick off the search, grabbing the iterator. + search_routine = self._search_for(search_value) + search_results = search_routine.__aiter__() + + # We're going to be doing the send/await dance in this code, so we + # need to grab the first yielded command to start things off. + try: + hit = await search_results.__anext__() + except StopAsyncIteration: + # We've been stopped before we've even really got going, likely + # because the user is very quick on the keyboard. + hit = None + + while hit: + # Turn the command into something for display, and add it to the + # list of commands that have been gathered so far. + prompt = hit.match_display + if hit.command_help: + prompt = Group(prompt, Text(hit.command_help, style=help_style)) + gathered_commands.append(Command(prompt, hit, id=str(command_id))) + + # Before we go making any changes to the UI, we do a quick + # double-check that the worker hasn't been cancelled. There's + # little point in doing UI work on a value that isn't needed any + # more. + if worker.is_cancelled: + break + + # Having made it this far, it's safe to update the list of + # commands that match the input. Note that we batch up the + # results and only refresh the list once every so often; this + # helps reduce how much UI work needs to be done, but at the + # same time we keep the update frequency often enough so that it + # looks like things are moving along. + now = monotonic() + if (now - last_update) > self._RESULT_BATCH_TIME: + self._refresh_command_list( + command_list, gathered_commands, clear_current + ) + clear_current = False + last_update = now + + # Bump the ID. + command_id += 1 + + # Finally, get the available command from the incoming queue; + # note that we send the worker cancelled status down into the + # search method. + try: + hit = await search_routine.asend(worker.is_cancelled) + except StopAsyncIteration: + break + + # On the way out, if we're still in play, ensure everything has been + # dropped into the command list. + if not worker.is_cancelled: + self._refresh_command_list(command_list, gathered_commands, clear_current) + + # One way or another, we're not busy any more. + self._show_busy = False + + # If we didn't get any hits, and we're not cancelled, that would + # mean nothing was found. Give the user positive feedback to that + # effect. + if command_list.option_count == 0 and not worker.is_cancelled: + command_list.add_option( + Option( + Align.center(Text("No matches found")), + disabled=True, + id=self._NO_MATCHES, + ) + ) + + @on(Input.Changed) + def _input(self, event: Input.Changed) -> None: + """React to input in the command palette. + + Args: + event: The input event. + """ + self.workers.cancel_all() + search_value = event.value.strip() + if search_value: + self._gather_commands(search_value) + else: + self._list_visible = False + self.query_one(CommandList).clear_options() + + @on(OptionList.OptionSelected) + def _select_command(self, event: OptionList.OptionSelected) -> None: + """React to a command being selected from the dropdown. + + Args: + event: The option selection event. + """ + event.stop() + self.workers.cancel_all() + input = self.query_one(CommandInput) + with self.prevent(Input.Changed): + assert isinstance(event.option, Command) + input.value = str(event.option.command.command_text) + self._selected_command = event.option.command + input.action_end() + self._list_visible = False + self.query_one(CommandList).clear_options() + if self.run_on_select: + self._select_or_command() + + @on(Input.Submitted) + @on(Button.Pressed) + def _select_or_command(self) -> None: + """Depending on context, select or execute a command.""" + # If the list is visible, that means we're in "pick a command" + # mode... + if self._list_visible: + # ...so if nothing in the list is highlighted yet... + if self.query_one(CommandList).highlighted is None: + # ...cause the first completion to be highlighted. + self._action_cursor_down() + else: + # The list is visible, something is highlighted, the user + # made a selection "gesture"; let's go select it! + self._action_command_list("select") + else: + # The list isn't visible, which means that if we have a + # command... + if self._selected_command is not None: + # ...we should return it to the parent screen and let it + # decide what to do with it (hopefully it'll run it). + self.workers.cancel_all() + self.dismiss(self._selected_command.command) + + def _action_escape(self) -> None: + """Handle a request to escape out of the command palette.""" + if self._list_visible: + self._list_visible = False + else: + self.workers.cancel_all() + self.dismiss() + + def _action_command_list(self, action: str) -> None: + """Pass an action on to the [`CommandList`][textual.command_palette.CommandList]. + + Args: + action: The action to pass on to the [`CommandList`][textual.command_palette.CommandList]. + """ + try: + command_action = getattr(self.query_one(CommandList), f"action_{action}") + except AttributeError: + return + command_action() + + def _action_cursor_down(self) -> None: + """Handle the cursor down action. + + This allows the cursor down key to either open the command list, if + it's closed but has options, or if it's open with options just + cursor through them. + """ + commands = self.query_one(CommandList) + if commands.option_count and not self._list_visible: + self._list_visible = True + commands.highlighted = 0 + elif ( + commands.option_count + and not commands.get_option_at_index(0).id == self._NO_MATCHES + ): + self._action_command_list("cursor_down") diff --git a/src/textual/screen.py b/src/textual/screen.py index a999930c8..88df054aa 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -49,6 +49,8 @@ from .widgets._toast import ToastRack if TYPE_CHECKING: from typing_extensions import Final + from .command_palette import CommandSource + # Unused & ignored imports are needed for the docs to link to these objects: from .errors import NoWidget # type: ignore # noqa: F401 from .message_pump import MessagePump @@ -155,6 +157,9 @@ class Screen(Generic[ScreenResultType], Widget): title: Reactive[str | None] = Reactive(None, compute=False) """Screen title to override [the app title][textual.app.App.title].""" + COMMAND_SOURCES: ClassVar[set[type[CommandSource]]] = set() + """The [command sources](/api/command_palette/) for the screen.""" + BINDINGS = [ Binding("tab", "focus_next", "Focus Next", show=False), Binding("shift+tab", "focus_previous", "Focus Previous", show=False), diff --git a/tests/command_palette/test_click_away.py b/tests/command_palette/test_click_away.py new file mode 100644 index 000000000..e2fe6915c --- /dev/null +++ b/tests/command_palette/test_click_away.py @@ -0,0 +1,30 @@ +from textual.app import App +from textual.command_palette import ( + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) + + +class SimpleSource(CommandSource): + async def search(self, query: str) -> CommandMatches: + def goes_nowhere_does_nothing() -> None: + pass + + yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMAND_SOURCES = {SimpleSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_clicking_outside_command_palette_closes_it() -> None: + """Clicking 'outside' the command palette should make it go away.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.click() + assert len(pilot.app.query(CommandPalette)) == 0 diff --git a/tests/command_palette/test_command_source_environment.py b/tests/command_palette/test_command_source_environment.py new file mode 100644 index 000000000..11e632bb7 --- /dev/null +++ b/tests/command_palette/test_command_source_environment.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.command_palette import ( + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Input + + +class SimpleSource(CommandSource): + environment: set[tuple[App, Screen, Widget | None]] = set() + + async def search(self, _: str) -> CommandMatches: + def goes_nowhere_does_nothing() -> None: + pass + + SimpleSource.environment.add((self.app, self.screen, self.focused)) + yield CommandSourceHit(1, "Hit", goes_nowhere_does_nothing, "Hit") + + +class CommandPaletteApp(App[None]): + COMMAND_SOURCES = {SimpleSource} + + def compose(self) -> ComposeResult: + yield Input() + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_command_source_environment() -> None: + """The command source should see the app and default screen.""" + async with CommandPaletteApp().run_test() as pilot: + base_screen = pilot.app.query_one(CommandPalette)._calling_screen + assert base_screen is not None + await pilot.press(*"test") + assert len(SimpleSource.environment) == 1 + assert SimpleSource.environment == { + (pilot.app, base_screen, base_screen.query_one(Input)) + } diff --git a/tests/command_palette/test_declare_sources.py b/tests/command_palette/test_declare_sources.py new file mode 100644 index 000000000..cac9f5205 --- /dev/null +++ b/tests/command_palette/test_declare_sources.py @@ -0,0 +1,102 @@ +from textual.app import App +from textual.command_palette import ( + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) +from textual.screen import Screen + + +async def test_sources_with_no_known_screen() -> None: + """A command palette with no known screen should have an empty source set.""" + assert CommandPalette()._sources == set() + + +class ExampleCommandSource(CommandSource): + async def search(self, _: str) -> CommandMatches: + def goes_nowhere_does_nothing() -> None: + pass + + yield CommandSourceHit(1, "Hit", goes_nowhere_does_nothing, "Hit") + + +class AppWithActiveCommandPalette(App[None]): + def on_mount(self) -> None: + self.action_command_palette() + + +class AppWithNoSources(AppWithActiveCommandPalette): + pass + + +async def test_no_app_command_sources() -> None: + """An app with no sources declared should work fine.""" + async with AppWithNoSources().run_test() as pilot: + assert pilot.app.query_one(CommandPalette)._sources == App.COMMAND_SOURCES + + +class AppWithSources(AppWithActiveCommandPalette): + COMMAND_SOURCES = {ExampleCommandSource} + + +async def test_app_command_sources() -> None: + """Command sources declared on an app should be in the command palette.""" + async with AppWithSources().run_test() as pilot: + assert ( + pilot.app.query_one(CommandPalette)._sources + == AppWithSources.COMMAND_SOURCES + ) + + +class AppWithInitialScreen(App[None]): + def __init__(self, screen: Screen) -> None: + super().__init__() + self._test_screen = screen + + def on_mount(self) -> None: + self.push_screen(self._test_screen) + + +class ScreenWithNoSources(Screen[None]): + def on_mount(self) -> None: + self.app.action_command_palette() + + +async def test_no_screen_command_sources() -> None: + """An app with a screen with no sources declared should work fine.""" + async with AppWithInitialScreen(ScreenWithNoSources()).run_test() as pilot: + assert pilot.app.query_one(CommandPalette)._sources == App.COMMAND_SOURCES + + +class ScreenWithSources(ScreenWithNoSources): + COMMAND_SOURCES = {ExampleCommandSource} + + +async def test_screen_command_sources() -> None: + """Command sources declared on a screen should be in the command palette.""" + async with AppWithInitialScreen(ScreenWithSources()).run_test() as pilot: + assert ( + pilot.app.query_one(CommandPalette)._sources + == App.COMMAND_SOURCES | ScreenWithSources.COMMAND_SOURCES + ) + + +class AnotherCommandSource(ExampleCommandSource): + pass + + +class CombinedSourceApp(App[None]): + COMMAND_SOURCES = {AnotherCommandSource} + + def on_mount(self) -> None: + self.push_screen(ScreenWithSources()) + + +async def test_app_and_screen_command_sources_combine() -> None: + """If an app and the screen have command sources they should combine.""" + async with CombinedSourceApp().run_test() as pilot: + assert ( + pilot.app.query_one(CommandPalette)._sources + == CombinedSourceApp.COMMAND_SOURCES | ScreenWithSources.COMMAND_SOURCES + ) diff --git a/tests/command_palette/test_escaping.py b/tests/command_palette/test_escaping.py new file mode 100644 index 000000000..1dbf48337 --- /dev/null +++ b/tests/command_palette/test_escaping.py @@ -0,0 +1,55 @@ +from textual.app import App +from textual.command_palette import ( + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) + + +class SimpleSource(CommandSource): + async def search(self, query: str) -> CommandMatches: + def goes_nowhere_does_nothing() -> None: + pass + + yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMAND_SOURCES = {SimpleSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_escape_closes_when_no_list_visible() -> None: + """Pressing escape when no list is visible should close the command palette.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 0 + + +async def test_escape_does_not_close_when_list_visible() -> None: + """Pressing escape when a hit list is visible should not close the command palette.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("a") + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 0 + + +async def test_down_arrow_should_undo_closing_of_list_via_escape() -> None: + """Down arrow should reopen the hit list if escape closed it before.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("a") + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("down") + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 1 + await pilot.press("escape") + assert len(pilot.app.query(CommandPalette)) == 0 diff --git a/tests/command_palette/test_interaction.py b/tests/command_palette/test_interaction.py new file mode 100644 index 000000000..9dcbb90bb --- /dev/null +++ b/tests/command_palette/test_interaction.py @@ -0,0 +1,72 @@ +from textual.app import App +from textual.command_palette import ( + CommandList, + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) + + +class SimpleSource(CommandSource): + async def search(self, query: str) -> CommandMatches: + def goes_nowhere_does_nothing() -> None: + pass + + for _ in range(100): + yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMAND_SOURCES = {SimpleSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_initial_list_no_highlight() -> None: + """When the list initially appears, nothing will be highlighted.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + assert pilot.app.query_one(CommandList).visible is False + await pilot.press("a") + assert pilot.app.query_one(CommandList).visible is True + assert pilot.app.query_one(CommandList).highlighted is None + + +async def test_down_arrow_selects_an_item() -> None: + """Typing in a search value then pressing down should select a command.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + assert pilot.app.query_one(CommandList).visible is False + await pilot.press("a") + assert pilot.app.query_one(CommandList).visible is True + assert pilot.app.query_one(CommandList).highlighted is None + await pilot.press("down") + assert pilot.app.query_one(CommandList).highlighted is not None + + +async def test_enter_selects_an_item() -> None: + """Typing in a search value then pressing enter should select a command.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + assert pilot.app.query_one(CommandList).visible is False + await pilot.press("a") + assert pilot.app.query_one(CommandList).visible is True + assert pilot.app.query_one(CommandList).highlighted is None + await pilot.press("enter") + assert pilot.app.query_one(CommandList).highlighted is not None + + +async def test_selection_of_command_closes_command_palette() -> None: + """Selecting a command from the list should close the list.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + assert pilot.app.query_one(CommandList).visible is False + await pilot.press("a") + assert pilot.app.query_one(CommandList).visible is True + assert pilot.app.query_one(CommandList).highlighted is None + await pilot.press("enter") + assert pilot.app.query_one(CommandList).highlighted is not None + await pilot.press("enter") + assert len(pilot.app.query(CommandPalette)) == 0 diff --git a/tests/command_palette/test_no_results.py b/tests/command_palette/test_no_results.py new file mode 100644 index 000000000..9ea99185d --- /dev/null +++ b/tests/command_palette/test_no_results.py @@ -0,0 +1,25 @@ +from textual.app import App +from textual.command_palette import CommandPalette +from textual.widgets import OptionList + + +class CommandPaletteApp(App[None]): + COMMAND_SOURCES = set() + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_no_results() -> None: + """Receiving no results from a search for a command should not be a problem.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.query(CommandPalette)) == 1 + results = pilot.app.screen.query_one(OptionList) + assert results.visible is False + assert results.option_count == 0 + await pilot.press("a") + await pilot.pause() + assert results.visible is True + assert results.option_count == 1 + assert "No matches found" in str(results.get_option_at_index(0).prompt) + assert results.get_option_at_index(0).disabled is True diff --git a/tests/command_palette/test_run_on_select.py b/tests/command_palette/test_run_on_select.py new file mode 100644 index 000000000..9b010bb3f --- /dev/null +++ b/tests/command_palette/test_run_on_select.py @@ -0,0 +1,72 @@ +from functools import partial + +from textual.app import App +from textual.command_palette import ( + CommandMatches, + CommandPalette, + CommandSource, + CommandSourceHit, +) +from textual.widgets import Input + + +class SimpleSource(CommandSource): + async def search(self, _: str) -> CommandMatches: + def goes_nowhere_does_nothing(selection: int) -> None: + assert isinstance(self.app, CommandPaletteRunOnSelectApp) + self.app.selection = selection + + for n in range(100): + yield CommandSourceHit( + n + 1 / 100, + str(n), + partial(goes_nowhere_does_nothing, n), + str(n), + f"This is help for {n}", + ) + + +class CommandPaletteRunOnSelectApp(App[None]): + COMMAND_SOURCES = {SimpleSource} + + def __init__(self) -> None: + super().__init__() + self.selection: int | None = None + + +async def test_with_run_on_select_on() -> None: + """With run on select on, the callable should be instantly run.""" + async with CommandPaletteRunOnSelectApp().run_test() as pilot: + save = CommandPalette.run_on_select + CommandPalette.run_on_select = True + assert isinstance(pilot.app, CommandPaletteRunOnSelectApp) + pilot.app.action_command_palette() + await pilot.press("0") + await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + await pilot.press("down") + await pilot.press("enter") + assert pilot.app.selection is not None + CommandPalette.run_on_select = save + + +class CommandPaletteDoNotRunOnSelectApp(CommandPaletteRunOnSelectApp): + def __init__(self) -> None: + super().__init__() + + +async def test_with_run_on_select_off() -> None: + """With run on select off, the callable should not be instantly run.""" + async with CommandPaletteDoNotRunOnSelectApp().run_test() as pilot: + save = CommandPalette.run_on_select + CommandPalette.run_on_select = False + assert isinstance(pilot.app, CommandPaletteDoNotRunOnSelectApp) + pilot.app.action_command_palette() + await pilot.press("0") + await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + await pilot.press("down") + await pilot.press("enter") + assert pilot.app.selection is None + assert pilot.app.query_one(Input).value != "" + await pilot.press("enter") + assert pilot.app.selection is not None + CommandPalette.run_on_select = save diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 65b7ed96c..11e7c0d64 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1855,6 +1855,166 @@ ''' # --- +# name: test_command_palette + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CommandPaletteApp + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎A + + + This is a test of this code 9 + This is a test of this code 8 + This is a test of this code 7 + This is a test of this code 6 + This is a test of this code 5 + This is a test of this code 4 + This is a test of this code 3 + This is a test of this code 2 + This is a test of this code 1 + This is a test of this code 0 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- # name: test_content_switcher_example_initial ''' diff --git a/tests/snapshot_tests/snapshot_apps/command_palette.py b/tests/snapshot_tests/snapshot_apps/command_palette.py new file mode 100644 index 000000000..06e1a3589 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/command_palette.py @@ -0,0 +1,25 @@ +from textual.app import App +from textual.command_palette import CommandSource, CommandMatches, CommandSourceHit + +class TestSource(CommandSource): + + def goes_nowhere_does_nothing(self) -> None: + pass + + async def search(self, query: str) -> CommandMatches: + matcher = self.matcher(query) + for n in range(10): + command = f"This is a test of this code {n}" + yield CommandSourceHit( + n/10, matcher.highlight(command), self.goes_nowhere_does_nothing, command + ) + +class CommandPaletteApp(App[None]): + + COMMAND_SOURCES = {TestSource} + + def on_mount(self) -> None: + self.action_command_palette() + +if __name__ == "__main__": + CommandPaletteApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 13a759d23..34500c63e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -599,6 +599,16 @@ def test_tooltips_in_compound_widgets(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "tooltips.py", run_before=run_before) +def test_command_palette(snap_compare) -> None: + + from textual.command_palette import CommandPalette + + async def run_before(pilot) -> None: + await pilot.press("ctrl+@") + await pilot.press("A") + await pilot.app.query_one(CommandPalette).workers.wait_for_complete() + assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette.py", run_before=run_before) + # --- textual-dev library preview tests --- diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 6e4c59b0f..6bb1728fa 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -39,7 +39,7 @@ class NoBindings(App[None]): async def test_just_app_no_bindings() -> None: """An app with no bindings should have no bindings, other than ctrl+c.""" async with NoBindings().run_test() as pilot: - assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c"] + assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "ctrl+@"] assert pilot.app._bindings.get_key("ctrl+c").priority is True @@ -60,7 +60,9 @@ class AlphaBinding(App[None]): async def test_just_app_alpha_binding() -> None: """An app with a single binding should have just the one binding.""" async with AlphaBinding().run_test() as pilot: - assert sorted(pilot.app._bindings.keys.keys()) == sorted(["ctrl+c", "a"]) + assert sorted(pilot.app._bindings.keys.keys()) == sorted( + ["ctrl+c", "ctrl+@", "a"] + ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is True @@ -82,7 +84,9 @@ class LowAlphaBinding(App[None]): async def test_just_app_low_priority_alpha_binding() -> None: """An app with a single low-priority binding should have just the one binding.""" async with LowAlphaBinding().run_test() as pilot: - assert sorted(pilot.app._bindings.keys.keys()) == sorted(["ctrl+c", "a"]) + assert sorted(pilot.app._bindings.keys.keys()) == sorted( + ["ctrl+c", "ctrl+@", "a"] + ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is False diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 71c073d9f..9b0e46cb0 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -1,3 +1,4 @@ +from rich.style import Style from rich.text import Span from textual._fuzzy import Matcher @@ -28,13 +29,12 @@ def test_highlight(): matcher = Matcher("foo.bar") spans = matcher.highlight("foo/egg.bar").spans - print(spans) assert spans == [ - Span(0, 1, "bold"), - Span(1, 2, "bold"), - Span(2, 3, "bold"), - Span(7, 8, "bold"), - Span(8, 9, "bold"), - Span(9, 10, "bold"), - Span(10, 11, "bold"), + Span(0, 1, Style(reverse=True)), + Span(1, 2, Style(reverse=True)), + Span(2, 3, Style(reverse=True)), + Span(7, 8, Style(reverse=True)), + Span(8, 9, Style(reverse=True)), + Span(9, 10, Style(reverse=True)), + Span(10, 11, Style(reverse=True)), ]