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
+ '''
+
+
+ '''
+# ---
# name: test_content_switcher_example_initial
'''